Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Auth/HubPairingService.swift
T
weishu 3363d1c75b feat(ios): pairing flow, deep link, app session wiring (A-M1d)
- HapiProtocol/Pairing/BindLink: parses the companion deeplink
  (hapicompanion://bind?hub=&code=) and the web direct-access QR
  (?hub=&token=) with URLSearchParams form-decoding semantics, in
  lockstep with the Android port (tests mirror BindLinkTest.kt).
- HapiClient/Auth/HubPairingService: normalize -> GET /health
  (reachability + protocolVersion) -> POST /api/auth -> persist
  Keychain + registry + active hub; unpair with fallback. Covered by
  PairingLogicTests through the HTTPPerforming seam.
- App layer: AppModel (@Observable @MainActor pairing state machine:
  restore, pair, switch, sign out, deep-link routing, scenePhase,
  terminal-auth-failure banner) + HubSession (per-active-hub APIClient/
  AuthManager/global SSEClient with suspend-resume and a connection
  state for the UI; store routing is TODO(M2)).
- Pairing UI: welcome flow, VisionKit QR scanner (with Simulator/
  permission fallbacks), manual entry (paste-friendly), shared confirm
  sheet with per-PairingFailure error states.
- HapiApp routes hapicompanion:// through AppModel (paired hubs switch
  with a notice, never log the token); RootView switches unpaired/
  paired and hosts the deep-link confirm sheet; HomePlaceholderView
  shows hub, connection dot, hub switcher (M2a replaces it with the
  session list).
- Info.plist: NSCameraUsageDescription; README: pairing guide + manual
  test pass.
2026-08-17 15:58:17 +08:00

155 lines
6.3 KiB
Swift

import Foundation
import HapiProtocol
/// Why a pairing attempt failed, shaped for direct UI presentation
/// (the pairing screen's error states).
public enum PairingFailure: Error, Equatable, Sendable {
/// The typed/scanned hub URL is not an absolute http(s) URL.
case invalidHubURL
/// The hub could not be reached, or whatever answered `GET /health` was
/// not a healthy HAPI hub (non-2xx, undecodable body, `status != "ok"`).
case unreachable
/// The hub speaks a different protocol generation than this build.
case protocolMismatch(hubVersion: Int, supportedVersion: Int)
/// `POST /api/auth` answered 401 — the access token is wrong or rotated.
case invalidAccessToken
/// `POST /api/auth` answered an unexpected status.
case hubError(status: Int)
/// The exchange succeeded but the credentials could not be persisted
/// (Keychain write failure).
case storageFailure
}
/// Result of a successful pairing.
public struct PairedHub: Equatable, Sendable {
/// Normalized hub origin — the key everything else uses.
public let hubUrl: String
/// Claims of the freshly issued JWT (`ns == "default"` gates owner-only
/// surfaces). `nil` only if the hub issued an unparseable token.
public let claims: JWTClaims?
public init(hubUrl: String, claims: JWTClaims?) {
self.hubUrl = hubUrl
self.claims = claims
}
}
/// The pairing/unpairing sequence, kept UI-free so it is testable through the
/// ``HTTPPerforming`` seam (see `PairingLogicTests`).
///
/// `pair` follows `docs/api/client-contract/auth.md`:
/// 1. normalize the hub URL (`HubURLNormalization`),
/// 2. `GET /health` — reachability plus `protocolVersion` compatibility,
/// 3. `POST /api/auth {accessToken}` — the token is opaque, passed verbatim,
/// 4. persist ``HubCredentials`` (access token + first JWT) in the credential
/// store, register the hub, and make it active.
///
/// `unpair` deletes the credentials, drops the hub from the registry, and
/// reports which hub (if any) is active afterwards. The app layer owns
/// everything session-shaped (building/tearing down `HubSession`).
public struct HubPairingService: Sendable {
private let registry: HubRegistry
private let credentialStore: any CredentialStoring
private let performer: any HTTPPerforming
private let now: @Sendable () -> Date
public init(
registry: HubRegistry,
credentialStore: any CredentialStoring,
performer: any HTTPPerforming = URLSessionHTTPPerformer.shared,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.registry = registry
self.credentialStore = credentialStore
self.performer = performer
self.now = now
}
/// Pairs with the hub at `rawHubUrl` using `accessToken`. On success the
/// hub is registered, active, and its credentials are persisted (an
/// existing record for the same hub is replaced — re-pairing refreshes a
/// rotated token). Throws ``PairingFailure``.
public func pair(rawHubUrl: String, accessToken: String) async throws -> PairedHub {
guard let normalized = HubURLNormalization.normalize(rawHubUrl),
let baseURL = URL(string: normalized) else {
throw PairingFailure.invalidHubURL
}
let token = accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
throw PairingFailure.invalidAccessToken
}
// Throwaway client: both calls are unauthenticated, so the auth
// manager is wiring-only and never consulted.
let client = APIClient(
baseURL: baseURL,
authManager: AuthManager(baseURL: baseURL, credentialStore: credentialStore, performer: performer),
performer: performer
)
// 1. Reachability + protocol compatibility.
let health: HubHealthResponse
do {
health = try await client.health()
} catch {
// Non-2xx, transport failure, or a 200 that does not decode as a
// hub health body — nothing at this address we can pair with.
throw PairingFailure.unreachable
}
guard health.status == "ok" else {
throw PairingFailure.unreachable
}
// The protocol generation is a single integer (shared/src/version.ts);
// this build understands exactly one.
guard health.protocolVersion == ProtocolVersion.supported else {
throw PairingFailure.protocolMismatch(
hubVersion: health.protocolVersion,
supportedVersion: ProtocolVersion.supported
)
}
// 2. Token exchange.
let auth: AuthResponse
do {
auth = try await client.authenticate(accessToken: token)
} catch let error as APIError {
throw error.status == 401
? PairingFailure.invalidAccessToken
: PairingFailure.hubError(status: error.status)
} catch {
throw PairingFailure.unreachable
}
// 3. Persist. Credentials first: a hub registered without
// credentials would restore as paired-but-dead.
do {
try credentialStore.store(HubCredentials(
hubUrl: normalized,
accessToken: token,
jwt: auth.token,
jwtObtainedAt: Int(now().timeIntervalSince1970 * 1000)
))
} catch {
throw PairingFailure.storageFailure
}
registry.register(normalized)
registry.setActiveHub(normalized)
return PairedHub(hubUrl: normalized, claims: JWT.claims(from: auth.token))
}
/// Removes a hub: credentials deleted, registry entry dropped, active
/// selection falls back per ``HubRegistry`` rules. Returns the hub that is
/// active afterwards, or `nil` when none remain (back to unpaired).
///
/// TODO(M4a-push): once device push registration exists, unregister the
/// device here first, while a valid JWT is still obtainable.
@discardableResult
public func unpair(hubUrl: String) -> String? {
let normalized = HubURLNormalization.normalize(hubUrl) ?? hubUrl
try? credentialStore.deleteCredentials(forHub: normalized)
registry.remove(normalized)
return registry.activeHub
}
}