Jun 24 - Implement device registry
This commit is contained in:
@@ -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) ?? "<binary>"
|
||||
print("[JQC] registerDevice HTTP \(status): \(raw)")
|
||||
}
|
||||
}
|
||||
print("[JQC] registerDevice error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue Comments (Phase D) ──────────────────────────────────────────
|
||||
|
||||
/// Fetch all comments for an issue, oldest-first.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user