// Sources/Core/Push/DeviceTokenRegistrar.swift // T-2.9: APNs — device token storage + deferred sidecar registration import UIKit /// Receives the APNs device token and registers it with the paired sidecar. /// /// **Phase 2 note:** the sidecar does not yet expose a dedicated /// `/device-token` endpoint. The token and a *pending* flag are stored in /// `UserDefaults`; `registerWithSidecar` will be fully wired once the /// endpoint lands in a follow-up task. Until then the method records the /// intent and returns without making a network call. actor DeviceTokenRegistrar { // MARK: - Singleton static let shared = DeviceTokenRegistrar() private init() {} // MARK: - UserDefaults keys private enum Keys { static let tokenHex = "piremote.push.tokenHex" static let registrationPending = "piremote.push.registrationPending" } // MARK: - State /// Hex-encoded 40-byte device token string (80 hex chars). private(set) var tokenHex: String? = nil // MARK: - Token ingestion /// Store the raw token data received from `didRegisterForRemoteNotificationsWithDeviceToken`. func didRegister(tokenData: Data) { let hex = tokenData.map { String(format: "%02x", $0) }.joined() tokenHex = hex UserDefaults.standard.set(hex, forKey: Keys.tokenHex) // Mark any previously-pending registration as still pending so it is // retried when `registerWithSidecar` is next called. if UserDefaults.standard.bool(forKey: Keys.registrationPending) == false { UserDefaults.standard.set(true, forKey: Keys.registrationPending) } } // MARK: - Registration /// Attempt to register the current device token with the paired sidecar. /// /// **Current behaviour (stub):** the sidecar endpoint is not yet /// available, so the method persists the token locally and marks the /// registration as pending. The pending flag will be consumed and cleared /// when the real endpoint is wired in the Phase 2 follow-up. /// /// - Parameter credential: The active `SidecarCredential` obtained from /// the Keychain after a successful pairing. /// - Throws: `DeviceTokenRegistrarError.noTokenAvailable` if the APNs /// token has not been received yet. func registerWithSidecar(credential: SidecarCredential) async throws { // Recover a previously-stored token if we don't have one in memory yet // (e.g. after a cold launch where `didRegister` fires after we call // `registerWithSidecar`). if tokenHex == nil { tokenHex = UserDefaults.standard.string(forKey: Keys.tokenHex) } guard tokenHex != nil else { throw DeviceTokenRegistrarError.noTokenAvailable } // ── Stub ──────────────────────────────────────────────────────────── // TODO(Phase 2 follow-up): POST to `://:/device-token` // with JSON body: // { "deviceToken": tokenHex, "environment": DeviceTokenRegistrar.environment } // Headers: Authorization: Bearer // On HTTP 200 → clear the pending flag. // // For now, just mark it as pending so it is retried when the // endpoint becomes available. // ──────────────────────────────────────────────────────────────────── UserDefaults.standard.set(true, forKey: Keys.registrationPending) } // MARK: - Environment /// APNs environment derived from the build configuration. /// /// Switch to `"production"` when distributing via TestFlight or the /// App Store (the aps-environment entitlement controls the actual /// environment; this string is sent to the sidecar for its records). nonisolated static var environment: String { #if DEBUG return "sandbox" #else return "production" #endif } // MARK: - Pending state helper /// `true` when a token has been stored but not yet acknowledged by the sidecar. nonisolated static var isRegistrationPending: Bool { UserDefaults.standard.bool(forKey: Keys.registrationPending) } } // MARK: - Errors enum DeviceTokenRegistrarError: Error, Sendable { /// `didRegisterForRemoteNotificationsWithDeviceToken` has not been called yet. case noTokenAvailable }