mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
Push payloads do not name a hub, so a tap always opened the session against the active hub. With several paired hubs, a session from another hub then failed to load until the user switched hubs by hand. When the active hub's list does not know the session, the tap now looks it up with GET /api/sessions/:id. Hubs are tried in the same active-first order, and 404/403 have the same meaning, as for the notification actions. If another hub owns the session, the app switches to it first; the new home screen then opens the chat. A miss or an unreachable hub opens against the active hub, as before. Single-hub installs skip the lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
666 lines
28 KiB
Swift
666 lines
28 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?
|
|
/// The active hub's session list already has this id (no lookup).
|
|
let activeHubKnowsSession: @MainActor (String) -> Bool
|
|
/// Tap-through navigation: switch to the owning hub when one was
|
|
/// located (nil = stay on the active hub), then stash the target
|
|
/// session for the home screen (Android `pendingOpenSessionId`).
|
|
let openSession: @MainActor (_ sessionId: String, _ hubUrl: 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:
|
|
await openSession(payload.sessionId)
|
|
case ActionID.allow:
|
|
guard payload.type == .permissionRequest, payload.supportsActions,
|
|
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 payload.type == .permissionRequest, payload.supportsActions,
|
|
let requestId = payload.requestId else { return }
|
|
let outcome = await actionRunner().deny(
|
|
sessionId: payload.sessionId,
|
|
requestId: requestId
|
|
)
|
|
await reportIfFailed(outcome, payload: payload)
|
|
case ActionID.reply:
|
|
guard payload.supportsActions,
|
|
payload.type == .ready || payload.type == .taskNotification else { return }
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Tap-through. The payload carries no hub, so with several paired hubs
|
|
/// a session the active list does not know is located first (active hub
|
|
/// first, like the actions). A miss or an unreachable owner falls back
|
|
/// to the active hub.
|
|
private func openSession(_ sessionId: String) async {
|
|
guard let environment else { return }
|
|
var owner: String?
|
|
if hubsActiveFirst().count > 1, !environment.activeHubKnowsSession(sessionId),
|
|
case .success(let hubUrl) = await actionRunner().locate(sessionId: sessionId) {
|
|
owner = hubUrl
|
|
}
|
|
environment.openSession(sessionId, owner)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// `GET /sessions/:id` — `.success` names the hub that owns the session
|
|
/// (notification tap-through).
|
|
func locate(sessionId: String) async -> PushActionOutcome {
|
|
await resolveAcrossHubs { api in
|
|
_ = try await api.session(id: sessionId)
|
|
}
|
|
}
|
|
|
|
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,
|
|
]
|
|
}
|
|
}
|