Jun 24 - Implement device registry
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user