Jun 24 - Implement device registry

This commit is contained in:
Nguyen Ngo
2026-06-25 10:26:22 -04:00
parent e6de1f01b6
commit c05f0029fb
4 changed files with 85 additions and 0 deletions
+68
View File
@@ -2,6 +2,7 @@
import Foundation import Foundation
import Combine import Combine
import UIKit
enum APIError: Error, LocalizedError, Sendable { enum APIError: Error, LocalizedError, Sendable {
case invalidURL case invalidURL
@@ -371,6 +372,73 @@ actor APIClient {
return try await request("/api/v1/stats/dashboard") 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) ?? "<binary>"
print("[JQC] registerDevice HTTP \(status): \(raw)")
}
}
print("[JQC] registerDevice error: \(error)")
}
}
// Issue Comments (Phase D) // Issue Comments (Phase D)
/// Fetch all comments for an issue, oldest-first. /// Fetch all comments for an issue, oldest-first.
+4
View File
@@ -58,6 +58,10 @@ class AuthManager: ObservableObject {
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken) KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user) applyUser(response.user)
isAuthenticated = true 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) { } catch APIError.serverError(let msg) {
errorMessage = msg errorMessage = msg
} catch APIError.networkError { } catch APIError.networkError {
+12
View File
@@ -42,6 +42,13 @@ struct ContentView: View {
// any failure (offline, parse error, etc.) so it never disrupts // any failure (offline, parse error, etc.) so it never disrupts
// normal app use. // normal app use.
await updateChecker.checkForUpdate() 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 // Stop the 60s notification poll when the app goes to background and
// restart it when it returns to the foreground. iOS suspends Tasks // restart it when it returns to the foreground. iOS suspends Tasks
@@ -60,6 +67,11 @@ struct ContentView: View {
SyncManager.shared.suspendPolling() SyncManager.shared.suspendPolling()
case .active: case .active:
SyncManager.shared.resumePolling() 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: default:
break break
} }
+1
View File
@@ -62,6 +62,7 @@ nonisolated enum Constants {
static let userRole = "com.jqc.userRole" static let userRole = "com.jqc.userRole"
static let username = "com.jqc.username" static let username = "com.jqc.username"
static let displayName = "com.jqc.displayName" static let displayName = "com.jqc.displayName"
static let deviceId = "com.jqc.deviceId"
} }
static let tokenRefreshBufferMinutes: Double = 5 static let tokenRefreshBufferMinutes: Double = 5