From c05f0029fbb2ff32b161b9a279e59d3cb2d943ee Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 25 Jun 2026 10:26:22 -0400 Subject: [PATCH] Jun 24 - Implement device registry --- JanitorialQC/API/APIClient.swift | 68 +++++++++++++++++++++++++++++ JanitorialQC/Auth/AuthManager.swift | 4 ++ JanitorialQC/ContentView.swift | 12 +++++ JanitorialQC/Utils/Constants.swift | 1 + 4 files changed, 85 insertions(+) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 0700288..b9caa51 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -2,6 +2,7 @@ import Foundation import Combine +import UIKit enum APIError: Error, LocalizedError, Sendable { case invalidURL @@ -371,6 +372,73 @@ actor APIClient { return try await request("/api/v1/stats/dashboard") } + // ── Device Registration ─────────────────────────────────────────────── + // Called on every app foreground (active scenePhase) when authenticated. + // Upserts a device_registrations row on the server so the admin can see + // all installed devices and their versions. + // Errors are suppressed — device registration is best-effort and must + // never block the normal app launch flow. + + /// Returns or creates a stable device UUID, persisted in Keychain so it + /// survives app restarts but is unique per physical device. + nonisolated static func stableDeviceId() -> String { + if let existing = KeychainHelper.get(Constants.Keychain.deviceId) { + return existing + } + let new = UUID().uuidString + KeychainHelper.set(new, forKey: Constants.Keychain.deviceId) + return new + } + + func registerDevice() async { + guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { return } + + let deviceId = Self.stableDeviceId() + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + // UIDevice.current is @MainActor — read on MainActor then pass as plain Strings + let (deviceName, iosVersion): (String, String) = await MainActor.run { + (UIDevice.current.name, UIDevice.current.systemVersion) + } + + let body: [String: Any] = [ + "device_id": deviceId, + "device_name": deviceName, + "app_version": appVersion, + "ios_version": iosVersion, + ] + + do { + // nonisolated init required — SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor + // taints synthesised Decodable inits (CLAUDE.md rule 29). + struct R: Decodable, Sendable { + let registered: Bool + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + registered = try c.decode(Bool.self, forKey: .registered) + } + private enum CodingKeys: String, CodingKey { case registered } + } + let _: R = try await request("/api/v1/devices/register", method: "POST", body: body) + print("[JQC] registerDevice succeeded") + } catch { + // Log raw response to diagnose server-side failures + if let url = URL(string: ServerConfig.current + "/api/v1/devices/register"), + let token = KeychainHelper.get(Constants.Keychain.accessToken) { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + if let (data, resp) = try? await URLSession.shared.data(for: req) { + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + let raw = String(data: data, encoding: .utf8) ?? "" + print("[JQC] registerDevice HTTP \(status): \(raw)") + } + } + print("[JQC] registerDevice error: \(error)") + } + } + // ── Issue Comments (Phase D) ────────────────────────────────────────── /// Fetch all comments for an issue, oldest-first. diff --git a/JanitorialQC/Auth/AuthManager.swift b/JanitorialQC/Auth/AuthManager.swift index 5fac3be..4fc25d8 100644 --- a/JanitorialQC/Auth/AuthManager.swift +++ b/JanitorialQC/Auth/AuthManager.swift @@ -58,6 +58,10 @@ class AuthManager: ObservableObject { KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken) applyUser(response.user) isAuthenticated = true + // Register device immediately after login — the .task {} and + // .onChange(scenePhase) paths both miss this case because they run + // before login completes. + Task { await APIClient.shared.registerDevice() } } catch APIError.serverError(let msg) { errorMessage = msg } catch APIError.networkError { diff --git a/JanitorialQC/ContentView.swift b/JanitorialQC/ContentView.swift index ebf73e3..4148665 100644 --- a/JanitorialQC/ContentView.swift +++ b/JanitorialQC/ContentView.swift @@ -42,6 +42,13 @@ struct ContentView: View { // any failure (offline, parse error, etc.) so it never disrupts // normal app use. await updateChecker.checkForUpdate() + // 5. Register device now that auth is fully resolved. + // The .onChange(scenePhase == .active) fires BEFORE restoreSession() + // completes on first launch, so auth.isAuthenticated is false there + // and registration is skipped. This call covers that gap. + if AuthManager.shared.isAuthenticated { + await APIClient.shared.registerDevice() + } } // Stop the 60s notification poll when the app goes to background and // restart it when it returns to the foreground. iOS suspends Tasks @@ -60,6 +67,11 @@ struct ContentView: View { SyncManager.shared.suspendPolling() case .active: SyncManager.shared.resumePolling() + // Register / update device record on every foreground. + // Fire-and-forget — auth guard is inside registerDevice(). + if auth.isAuthenticated { + Task { await APIClient.shared.registerDevice() } + } default: break } diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift index 1835743..5811407 100644 --- a/JanitorialQC/Utils/Constants.swift +++ b/JanitorialQC/Utils/Constants.swift @@ -62,6 +62,7 @@ nonisolated enum Constants { static let userRole = "com.jqc.userRole" static let username = "com.jqc.username" static let displayName = "com.jqc.displayName" + static let deviceId = "com.jqc.deviceId" } static let tokenRefreshBufferMinutes: Double = 5