114 lines
4.6 KiB
Swift
114 lines
4.6 KiB
Swift
// 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 `<scheme>://<host>:<port>/device-token`
|
|
// with JSON body:
|
|
// { "deviceToken": tokenHex, "environment": DeviceTokenRegistrar.environment }
|
|
// Headers: Authorization: Bearer <credential.bearerToken>
|
|
// 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
|
|
}
|