mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
637 lines
26 KiB
Swift
637 lines
26 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import Observation
|
|
import OSLog
|
|
import Security
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
/// `@UIApplicationDelegateAdaptor` bridge: APNs registration callbacks and
|
|
/// the notification-center delegate hookup, forwarded to ``PushCoordinator``.
|
|
/// The delegate must be installed before `didFinishLaunching` returns so a
|
|
/// notification tap that cold-starts the app is delivered to it.
|
|
@MainActor
|
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
|
func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
let coordinator = PushCoordinator.shared
|
|
UNUserNotificationCenter.current().delegate = coordinator
|
|
coordinator.registerNotificationCategories()
|
|
return true
|
|
}
|
|
|
|
func application(
|
|
_ application: UIApplication,
|
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
|
) {
|
|
PushCoordinator.shared.handleAPNsToken(deviceToken)
|
|
}
|
|
|
|
func application(
|
|
_ application: UIApplication,
|
|
didFailToRegisterForRemoteNotificationsWithError error: Error
|
|
) {
|
|
PushCoordinator.shared.handleAPNsRegistrationFailure(error)
|
|
}
|
|
}
|
|
|
|
/// Everything push: APNs token lifecycle, per-hub device registration, the
|
|
/// end-to-end push key, notification categories/actions, foreground
|
|
/// suppression, tap-through navigation, and action handling against the hub
|
|
/// REST — the iOS mirror of the Android `DeviceRegistrar` +
|
|
/// `PushActionRunner` + `NotificationActionReceiver` stack, collapsed into
|
|
/// one main-actor coordinator because iOS has no background-process entry
|
|
/// points to split it across.
|
|
///
|
|
/// Registration triggers (Android parity, minus WorkManager):
|
|
/// - app becomes active while paired → re-register everywhere (cheap
|
|
/// upsert; heals reinstalls and hub-side pruning),
|
|
/// - APNs token minted/rotated → re-register everywhere,
|
|
/// - fresh pairing → permission prompt (first time) + register that hub,
|
|
/// - sign-out → best-effort `DELETE` from a credentials snapshot taken
|
|
/// before `AppModel` wipes the Keychain record.
|
|
/// Transient failures are healed by the next trigger (or the Settings
|
|
/// re-register button) instead of a background retry queue.
|
|
@MainActor
|
|
@Observable
|
|
final class PushCoordinator: NSObject {
|
|
static let shared = PushCoordinator()
|
|
|
|
/// Wiring provided by `AppModel` (the coordinator outlives any single
|
|
/// model — it is a process singleton because UIKit delegates are).
|
|
struct Environment {
|
|
let registry: HubRegistry
|
|
let credentialStore: any CredentialStoring
|
|
let performer: any HTTPPerforming
|
|
/// Session id of the chat currently on screen, for the one
|
|
/// suppression rule (`shouldSuppressPush` on Android).
|
|
let openChatSessionId: @MainActor () -> String?
|
|
/// Tap-through navigation: stash the target session for the home
|
|
/// screen's navigation path (Android `pendingOpenSessionId`).
|
|
let openSession: @MainActor (String) -> Void
|
|
}
|
|
|
|
// MARK: - Observable state (Settings row)
|
|
|
|
/// Last known notification-permission state.
|
|
private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined
|
|
/// Hubs whose most recent `POST /api/devices/register` succeeded this
|
|
/// launch.
|
|
private(set) var registeredHubs: Set<String> = []
|
|
/// Human-readable failure of the latest APNs/registration attempt.
|
|
private(set) var lastRegistrationProblem: String?
|
|
|
|
/// Paired hub origins (for the Settings "x of y" count). Snapshot, not
|
|
/// observable — refreshed whenever the observable fields change.
|
|
var pairedHubs: [String] { environment?.registry.hubs ?? [] }
|
|
|
|
@ObservationIgnored private var environment: Environment?
|
|
@ObservationIgnored private var apnsTokenHex: String?
|
|
@ObservationIgnored private let log = Logger(subsystem: "run.hapi.companion", category: "push")
|
|
|
|
// MARK: - Wiring (AppModel)
|
|
|
|
func configure(_ environment: Environment) {
|
|
self.environment = environment
|
|
}
|
|
|
|
/// Scene became active. While paired: refresh the permission state and —
|
|
/// when notifications are authorized — (re-)request the APNs token; its
|
|
/// arrival re-registers every hub.
|
|
func appDidBecomeActive(isPaired: Bool) {
|
|
Task {
|
|
await self.refreshAuthorizationStatus()
|
|
guard isPaired else { return }
|
|
if self.authorizationStatus == .authorized || self.authorizationStatus == .provisional {
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A hub was just paired: prompt for notification permission now (the
|
|
/// Android timing — never on the pristine first open, right away once a
|
|
/// hub actually exists), then register. If the APNs token is already in
|
|
/// hand the new hub registers immediately; otherwise the token callback
|
|
/// registers everything.
|
|
func hubPaired(_ hubUrl: String) {
|
|
Task {
|
|
let granted = await self.requestAuthorizationIfNeeded()
|
|
guard granted else { return }
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
if self.apnsTokenHex != nil {
|
|
await self.registerHubs([hubUrl])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Best-effort `DELETE /api/devices/register` for a hub about to be
|
|
/// signed out. Must be called **before** the credentials are wiped: the
|
|
/// record is snapshotted into an in-memory store so the detached DELETE
|
|
/// cannot race the Keychain deletion that follows.
|
|
func hubWillSignOut(_ hubUrl: String) {
|
|
registeredHubs.remove(hubUrl)
|
|
guard let environment,
|
|
let token = apnsTokenHex,
|
|
let url = URL(string: hubUrl),
|
|
let record = try? environment.credentialStore.credentials(forHub: hubUrl) else {
|
|
return
|
|
}
|
|
let snapshot = InMemoryCredentialStore()
|
|
try? snapshot.store(record)
|
|
let performer = environment.performer
|
|
Task.detached {
|
|
let auth = AuthManager(baseURL: url, credentialStore: snapshot, performer: performer)
|
|
let api = APIClient(baseURL: url, authManager: auth, performer: performer)
|
|
// Swallowed by design: the caller wipes the real credentials
|
|
// immediately, so a retry could never authenticate anyway.
|
|
try? await api.unregisterDevice(token: token)
|
|
}
|
|
}
|
|
|
|
/// A hub disappeared without a usable JWT (terminal auth failure) — no
|
|
/// unregister is possible; just drop it from the status row.
|
|
func hubRemoved(_ hubUrl: String) {
|
|
registeredHubs.remove(hubUrl)
|
|
}
|
|
|
|
/// Settings row: pull the current permission state (it can change in
|
|
/// the system Settings app at any time).
|
|
func refreshStatus() {
|
|
Task { await self.refreshAuthorizationStatus() }
|
|
}
|
|
|
|
/// Settings row action: (re-)prompt if never asked, then re-register
|
|
/// every paired hub with a fresh token.
|
|
func reregisterAll() {
|
|
Task {
|
|
let granted = await self.requestAuthorizationIfNeeded()
|
|
guard granted else { return }
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
if self.apnsTokenHex != nil, let hubs = self.environment?.registry.hubs {
|
|
await self.registerHubs(hubs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - APNs callbacks (AppDelegate)
|
|
|
|
func handleAPNsToken(_ deviceToken: Data) {
|
|
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
|
|
let changed = hex != apnsTokenHex
|
|
apnsTokenHex = hex
|
|
lastRegistrationProblem = nil
|
|
guard let hubs = environment?.registry.hubs, !hubs.isEmpty else { return }
|
|
// New token: everything must re-register. Same token: still run the
|
|
// upsert — this callback doubles as the app-start healing pass.
|
|
if changed {
|
|
registeredHubs.removeAll()
|
|
}
|
|
Task { await self.registerHubs(hubs) }
|
|
}
|
|
|
|
func handleAPNsRegistrationFailure(_ error: Error) {
|
|
// Simulators and entitlement-less builds land here; the status row
|
|
// surfaces it instead of dying silently.
|
|
lastRegistrationProblem = error.localizedDescription
|
|
log.warning("APNs registration failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
|
|
// MARK: - Permission
|
|
|
|
private func refreshAuthorizationStatus() async {
|
|
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
|
authorizationStatus = settings.authorizationStatus
|
|
}
|
|
|
|
/// Standard alert+sound+badge prompt (no provisional delivery — HAPI
|
|
/// pushes are actionable, quiet delivery would bury them).
|
|
private func requestAuthorizationIfNeeded() async -> Bool {
|
|
await refreshAuthorizationStatus()
|
|
switch authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
return true
|
|
case .denied:
|
|
return false
|
|
default:
|
|
let granted = (try? await UNUserNotificationCenter.current()
|
|
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
|
await refreshAuthorizationStatus()
|
|
return granted
|
|
}
|
|
}
|
|
|
|
// MARK: - Registration
|
|
|
|
private func registerHubs(_ hubs: [String]) async {
|
|
guard let environment, let token = apnsTokenHex else { return }
|
|
let deviceId = PushKeychain.ensureDeviceId()
|
|
guard let pushKey = PushKeychain.ensurePushKey() else {
|
|
lastRegistrationProblem = String(localized: "Could not store the push key.")
|
|
return
|
|
}
|
|
for hub in hubs {
|
|
// Re-check the roster per hub: a sign-out can land mid-loop.
|
|
guard environment.registry.hubs.contains(hub) else { continue }
|
|
guard let url = URL(string: hub) else { continue }
|
|
let auth = AuthManager(
|
|
baseURL: url,
|
|
credentialStore: environment.credentialStore,
|
|
performer: environment.performer
|
|
)
|
|
let api = APIClient(baseURL: url, authManager: auth, performer: environment.performer)
|
|
do {
|
|
try await api.registerDevice(token: token, deviceId: deviceId, pushKey: pushKey.base64)
|
|
registeredHubs.insert(hub)
|
|
} catch {
|
|
registeredHubs.remove(hub)
|
|
log.warning("Device registration failed for a hub: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Categories & actions
|
|
|
|
enum ActionID {
|
|
static let allow = "hapi.action.allow"
|
|
static let deny = "hapi.action.deny"
|
|
static let reply = "hapi.action.reply"
|
|
}
|
|
|
|
/// Category identifiers are the contract `type` strings — the same values
|
|
/// `PushPayload.categoryIdentifier` yields and the Notification Service
|
|
/// Extension stamps onto decrypted notifications.
|
|
func registerNotificationCategories() {
|
|
let allow = UNNotificationAction(
|
|
identifier: ActionID.allow,
|
|
title: String(localized: "Allow"),
|
|
options: []
|
|
)
|
|
let deny = UNNotificationAction(
|
|
identifier: ActionID.deny,
|
|
title: String(localized: "Deny"),
|
|
options: [.destructive]
|
|
)
|
|
let reply = UNTextInputNotificationAction(
|
|
identifier: ActionID.reply,
|
|
title: String(localized: "Reply"),
|
|
options: [],
|
|
textInputButtonTitle: String(localized: "Send"),
|
|
textInputPlaceholder: String(localized: "Message")
|
|
)
|
|
UNUserNotificationCenter.current().setNotificationCategories([
|
|
UNNotificationCategory(
|
|
identifier: "permission-request",
|
|
actions: [allow, deny],
|
|
intentIdentifiers: [],
|
|
options: []
|
|
),
|
|
UNNotificationCategory(
|
|
identifier: "ready",
|
|
actions: [reply],
|
|
intentIdentifiers: [],
|
|
options: []
|
|
),
|
|
UNNotificationCategory(
|
|
identifier: "task-notification",
|
|
actions: [reply],
|
|
intentIdentifiers: [],
|
|
options: []
|
|
),
|
|
])
|
|
}
|
|
|
|
// MARK: - Payload extraction
|
|
|
|
/// Decrypted field map the Notification Service Extension stores into
|
|
/// `userInfo` (see `HapiNotificationService/NotificationService.swift`).
|
|
static let decryptedUserInfoKey = "hapiDecrypted"
|
|
|
|
/// Reads the push payload from a delivered notification: the extension's
|
|
/// pre-decrypted field map when present, else a direct decrypt of the
|
|
/// `hapi.e` envelope (covers the rare NSE timeout/failure delivery).
|
|
private func payload(from userInfo: [AnyHashable: Any]) -> PushPayload? {
|
|
if let fields = userInfo[Self.decryptedUserInfoKey] as? [String: String] {
|
|
return PushPayload.parse(dictionary: fields)
|
|
}
|
|
guard let hapi = userInfo["hapi"] as? [String: Any],
|
|
let envelope = hapi["e"] as? String,
|
|
let keyData = PushKeychain.readPushKey(),
|
|
let plaintext = try? PushEnvelope.decrypt(envelopeBase64: envelope, key: keyData) else {
|
|
return nil
|
|
}
|
|
return PushPayload.parse(plaintext: plaintext)
|
|
}
|
|
}
|
|
|
|
// MARK: - UNUserNotificationCenterDelegate
|
|
|
|
extension PushCoordinator: UNUserNotificationCenterDelegate {
|
|
/// Foreground presentation. One suppression rule (Android
|
|
/// `shouldSuppressPush`): while the app is foreground **with that very
|
|
/// session's chat open**, the in-app SSE stream is already showing the
|
|
/// event — an OS banner on top would just be noise. Everything else
|
|
/// (other screens, other sessions, local failure notices) presents.
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification
|
|
) async -> UNNotificationPresentationOptions {
|
|
if let payload = payload(from: notification.request.content.userInfo),
|
|
let openId = environment?.openChatSessionId(),
|
|
openId == payload.sessionId {
|
|
return []
|
|
}
|
|
return [.banner, .list, .sound]
|
|
}
|
|
|
|
/// Tap and action handling. Runs inside the delegate's async completion,
|
|
/// which grants background execution for the REST round-trips.
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse
|
|
) async {
|
|
guard let payload = payload(from: response.notification.request.content.userInfo) else {
|
|
return
|
|
}
|
|
switch response.actionIdentifier {
|
|
case UNNotificationDefaultActionIdentifier:
|
|
environment?.openSession(payload.sessionId)
|
|
case ActionID.allow:
|
|
guard let requestId = payload.requestId else { return }
|
|
let outcome = await actionRunner().approve(
|
|
sessionId: payload.sessionId,
|
|
requestId: requestId
|
|
)
|
|
await reportIfFailed(outcome, payload: payload)
|
|
case ActionID.deny:
|
|
guard let requestId = payload.requestId else { return }
|
|
let outcome = await actionRunner().deny(
|
|
sessionId: payload.sessionId,
|
|
requestId: requestId
|
|
)
|
|
await reportIfFailed(outcome, payload: payload)
|
|
case ActionID.reply:
|
|
let text = (response as? UNTextInputNotificationResponse)?
|
|
.userText.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !text.isEmpty else { return }
|
|
let outcome = await actionRunner().sendMessage(
|
|
sessionId: payload.sessionId,
|
|
text: text,
|
|
localId: UUID().uuidString
|
|
)
|
|
await reportIfFailed(outcome, payload: payload)
|
|
default:
|
|
break // dismiss
|
|
}
|
|
}
|
|
|
|
private func actionRunner() -> PushActionRunner {
|
|
PushActionRunner(
|
|
hubs: hubsActiveFirst(),
|
|
credentialStore: environment?.credentialStore ?? InMemoryCredentialStore(),
|
|
performer: environment?.performer ?? URLSessionHTTPPerformer.shared
|
|
)
|
|
}
|
|
|
|
/// Paired hub origins with the active hub first — the try-order for
|
|
/// hub-resolving actions (the payload carries no hub URL; for the
|
|
/// overwhelmingly common single-hub setup the first try is the only try).
|
|
private func hubsActiveFirst() -> [String] {
|
|
guard let registry = environment?.registry else { return [] }
|
|
let hubs = registry.hubs
|
|
guard let active = registry.activeHub else { return hubs }
|
|
return [active] + hubs.filter { $0 != active }
|
|
}
|
|
|
|
/// The Android workers flip the notification through pending → done /
|
|
/// failed states; iOS dismisses the notification the moment an action is
|
|
/// chosen, so only the failures need a surface — a local notice, because
|
|
/// a silently lost reply is the worst outcome.
|
|
private func reportIfFailed(_ outcome: PushActionOutcome, payload: PushPayload) async {
|
|
let body: String?
|
|
switch outcome {
|
|
case .success:
|
|
body = nil
|
|
case .alreadyHandled:
|
|
body = String(localized: "Already handled elsewhere.")
|
|
case .sessionInactive:
|
|
body = String(localized: "The session is no longer active.")
|
|
case .sessionNotFound:
|
|
body = String(localized: "Session not found on any paired hub.")
|
|
case .failed, .transient:
|
|
body = String(localized: "Could not reach your hub. Open HAPI to respond.")
|
|
}
|
|
guard let body else { return }
|
|
let content = UNMutableNotificationContent()
|
|
content.title = payload.displayTitle
|
|
content.body = body
|
|
content.sound = nil
|
|
try? await UNUserNotificationCenter.current().add(
|
|
UNNotificationRequest(
|
|
identifier: "hapi.action.result.\(payload.sessionId)",
|
|
content: content,
|
|
trigger: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Hub-resolving action runner
|
|
|
|
/// Terminal state of one hub-resolving push action.
|
|
enum PushActionOutcome {
|
|
case success(hubUrl: String)
|
|
/// A hub recognized the session but the request is gone — decided
|
|
/// elsewhere or expired (404 `Request not found` past the session guard).
|
|
case alreadyHandled(hubUrl: String)
|
|
/// A hub recognized the session but it is inactive (409 `session_inactive`).
|
|
case sessionInactive(hubUrl: String)
|
|
/// No paired hub knows the session. Permanent.
|
|
case sessionNotFound
|
|
/// A hub rejected the call outright (unexpected 4xx). Permanent.
|
|
case failed(hubUrl: String, status: Int)
|
|
/// At least one hub failed transiently and none succeeded.
|
|
case transient
|
|
}
|
|
|
|
/// Executes notification actions (approve / deny / reply) against the right
|
|
/// hub — a line-for-line port of the Android `PushActionRunner`
|
|
/// (`android/core/data/.../push/PushActionRunner.kt`): try the active hub
|
|
/// first, treat 404 `Session not found` / 403 as "not this hub", stop at the
|
|
/// first hub that answers authoritatively.
|
|
struct PushActionRunner {
|
|
let hubs: [String]
|
|
let credentialStore: any CredentialStoring
|
|
let performer: any HTTPPerforming
|
|
|
|
/// `POST /sessions/:id/permissions/:rid/approve` with an empty `{}` body.
|
|
func approve(sessionId: String, requestId: String) async -> PushActionOutcome {
|
|
await resolveAcrossHubs { api in
|
|
try await api.approvePermission(sessionId: sessionId, requestId: requestId)
|
|
}
|
|
}
|
|
|
|
/// `POST /sessions/:id/permissions/:rid/deny` with an empty `{}` body.
|
|
func deny(sessionId: String, requestId: String) async -> PushActionOutcome {
|
|
await resolveAcrossHubs { api in
|
|
try await api.denyPermission(sessionId: sessionId, requestId: requestId)
|
|
}
|
|
}
|
|
|
|
/// `POST /sessions/:id/messages` `{text, localId}` (notification reply).
|
|
func sendMessage(sessionId: String, text: String, localId: String) async -> PushActionOutcome {
|
|
await resolveAcrossHubs { api in
|
|
try await api.sendMessage(sessionId: sessionId, text: text, localId: localId)
|
|
}
|
|
}
|
|
|
|
private func resolveAcrossHubs(
|
|
_ call: (APIClient) async throws -> Void
|
|
) async -> PushActionOutcome {
|
|
guard !hubs.isEmpty else { return .sessionNotFound }
|
|
var sawTransient = false
|
|
for hub in hubs {
|
|
guard let url = URL(string: hub) else { continue }
|
|
let auth = AuthManager(baseURL: url, credentialStore: credentialStore, performer: performer)
|
|
let api = APIClient(baseURL: url, authManager: auth, performer: performer)
|
|
do {
|
|
try await call(api)
|
|
return .success(hubUrl: hub)
|
|
} catch let error as APIError {
|
|
switch Self.classify(error) {
|
|
case .notThisHub:
|
|
continue
|
|
case .alreadyHandled:
|
|
return .alreadyHandled(hubUrl: hub)
|
|
case .sessionInactive:
|
|
return .sessionInactive(hubUrl: hub)
|
|
case .rejected:
|
|
return .failed(hubUrl: hub, status: error.status)
|
|
case .transient:
|
|
sawTransient = true
|
|
}
|
|
} catch is AuthError {
|
|
// This hub's credentials are unusable right now (terminal
|
|
// re-pair case or a failed silent re-auth) — move on.
|
|
continue
|
|
} catch {
|
|
sawTransient = true // transport-level: offline hub, DNS, timeout
|
|
}
|
|
}
|
|
return sawTransient ? .transient : .sessionNotFound
|
|
}
|
|
|
|
private enum HubVerdict {
|
|
case notThisHub, alreadyHandled, sessionInactive, rejected, transient
|
|
}
|
|
|
|
/// Maps one hub's ``APIError`` to a verdict. The two 404 flavors are told
|
|
/// apart by the hub's error string (surfaced as `APIError.code` pseudo-code
|
|
/// fallback): `Session not found` comes from the session guard — wrong
|
|
/// hub; `Request not found` comes after that guard passed — right hub,
|
|
/// request gone. An unrecognizable 404 body degrades to "not this hub".
|
|
private static func classify(_ error: APIError) -> HubVerdict {
|
|
switch true {
|
|
case error.status == 404 && error.code == "Request not found":
|
|
return .alreadyHandled
|
|
case error.status == 404, error.status == 403:
|
|
return .notThisHub
|
|
case error.status == 409 && error.code == "session_inactive":
|
|
return .sessionInactive
|
|
case error.status == 401, error.status == 408, error.status == 429:
|
|
return .transient
|
|
case error.status >= 500:
|
|
return .transient
|
|
default:
|
|
return .rejected
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Push Keychain (shared with the Notification Service Extension)
|
|
|
|
/// Keychain storage for the per-install push identity: the end-to-end
|
|
/// `pushKey` and the stable `deviceId`.
|
|
///
|
|
/// Both targets carry the keychain access group
|
|
/// `$(AppIdentifierPrefix)run.hapi.companion.push` as the **first** entry of
|
|
/// their `keychain-access-groups` entitlement. `kSecAttrAccessGroup` is
|
|
/// deliberately never passed: writes default to the first listed group (so
|
|
/// the items land in the shared group without hardcoding the team-id prefix,
|
|
/// which is unknowable at compile time), and reads search every accessible
|
|
/// group — the same lookup the extension performs.
|
|
///
|
|
/// `kSecAttrAccessibleAfterFirstUnlock` matches `KeychainCredentialStore`
|
|
/// and is required here: the extension must decrypt pushes while the device
|
|
/// is locked (any time after the first unlock since boot).
|
|
enum PushKeychain {
|
|
static let service = "run.hapi.companion.push"
|
|
static let pushKeyAccount = "pushKey"
|
|
static let deviceIdAccount = "deviceId"
|
|
|
|
/// The stored end-to-end key, if any (no generation — the extension-side
|
|
/// read uses the same query shape).
|
|
static func readPushKey() -> Data? {
|
|
read(account: pushKeyAccount)
|
|
}
|
|
|
|
/// The per-install key, generated **once** and regenerated only when the
|
|
/// stored value is missing or unusable (a regenerated key simply
|
|
/// re-registers on this same pass).
|
|
static func ensurePushKey() -> PushDeviceKey? {
|
|
if let data = read(account: pushKeyAccount), let key = PushDeviceKey(data: data) {
|
|
return key
|
|
}
|
|
let key = PushDeviceKey.generate()
|
|
guard write(account: pushKeyAccount, data: key.data) else { return nil }
|
|
return key
|
|
}
|
|
|
|
/// Stable install id for the hub's `(namespace, deviceId, platform)`
|
|
/// upsert. Falls back to an ephemeral UUID if the Keychain write fails —
|
|
/// registration still works, it just won't coalesce across reinstalls.
|
|
static func ensureDeviceId() -> String {
|
|
if let data = read(account: deviceIdAccount),
|
|
let existing = String(data: data, encoding: .utf8),
|
|
!existing.isEmpty {
|
|
return existing
|
|
}
|
|
let fresh = UUID().uuidString
|
|
_ = write(account: deviceIdAccount, data: Data(fresh.utf8))
|
|
return fresh
|
|
}
|
|
|
|
private static func read(account: String) -> Data? {
|
|
var query = baseQuery(account: account)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var item: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else {
|
|
return nil
|
|
}
|
|
return item as? Data
|
|
}
|
|
|
|
private static func write(account: String, data: Data) -> Bool {
|
|
let query = baseQuery(account: account)
|
|
let update: [String: Any] = [kSecValueData as String: data]
|
|
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
if updateStatus == errSecSuccess { return true }
|
|
guard updateStatus == errSecItemNotFound else { return false }
|
|
var add = query
|
|
add[kSecValueData as String] = data
|
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
|
|
}
|
|
|
|
private static func baseQuery(account: String) -> [String: Any] {
|
|
[
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecUseDataProtectionKeychain as String: true,
|
|
]
|
|
}
|
|
}
|