Files
hapi/ios/Hapi/Models/AppModel.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

259 lines
9.2 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Observation
import SwiftUI
/// A pairing that awaits user confirmation — from a deep link, a scanned QR,
/// or manual entry. Drives the confirm sheet.
struct PendingPairing: Identifiable, Hashable {
enum Source: Hashable {
case deepLink
case qrScan
case manual
}
let id = UUID()
/// Hub URL as carried by the link/entry (normalized during `pair`).
let hubUrl: String
let accessToken: String
let source: Source
}
/// Root application state: which hubs are paired, which one is active, and
/// the live ``HubSession`` for it. Owns the `HubRegistry` + credential store
/// and funnels every state transition (pair, switch, sign out, deep link,
/// scene phase, terminal auth failure) through one place.
@Observable @MainActor
final class AppModel {
enum AuthState: Equatable {
case unpaired
case paired(activeHub: String)
}
/// Derived from registry + credentials; every mutation goes through the
/// methods below so it can never drift.
private(set) var state: AuthState = .unpaired
/// Live session for the active hub (`nil` while unpaired).
private(set) var session: HubSession?
/// Paired hub origins, in pairing order (mirrors the registry, but
/// observable so menus update).
private(set) var hubs: [String] = []
/// Pairing waiting for confirmation (sheet presentation).
var pendingPairing: PendingPairing?
/// Presents the add-hub flow from the home screen. Lives here (not view
/// state) so a successful pair or an incoming deep link can close it.
var showAddHub = false
/// Origin of a hub that terminally rejected its credentials — shown as a
/// "pair again" banner until dismissed or re-paired.
var authFailureNotice: String?
/// Transient informational message (alert), e.g. deep link for an
/// already-paired hub.
var infoNotice: String?
// `let` storage is inert under @Observable (no annotation needed).
let registry: HubRegistry
private let credentialStore: any CredentialStoring
private let performer: any HTTPPerforming
private let pairingService: HubPairingService
@ObservationIgnored private var isForeground = false
init(
registry: HubRegistry = HubRegistry(),
credentialStore: any CredentialStoring = KeychainCredentialStore(),
performer: any HTTPPerforming = URLSessionHTTPPerformer.shared
) {
self.registry = registry
self.credentialStore = credentialStore
self.performer = performer
self.pairingService = HubPairingService(
registry: registry,
credentialStore: credentialStore,
performer: performer
)
restore()
}
// MARK: - Cold-start restore
/// Activates the stored selection — or the first registered hub that
/// still has credentials (a registry entry whose Keychain record is gone
/// is skipped rather than presented as paired-but-dead).
private func restore() {
hubs = registry.hubs
let candidates = [registry.activeHub].compactMap { $0 } + hubs
for hub in candidates where hasCredentials(for: hub) {
registry.setActiveHub(hub)
activate(hub: hub)
return
}
state = .unpaired
}
// MARK: - Pairing
/// Full pairing sequence (normalize → `/health` → `/api/auth` → persist,
/// see ``HubPairingService``); on success the hub becomes active with a
/// live session. Throws ``PairingFailure`` for the confirm sheet's error
/// states.
@discardableResult
func pair(hubUrl: String, accessToken: String) async throws -> PairedHub {
let paired = try await pairingService.pair(rawHubUrl: hubUrl, accessToken: accessToken)
authFailureNotice = nil
// Close whichever pairing surface was up; the state flip below swaps
// the root to the home screen.
pendingPairing = nil
showAddHub = false
activate(hub: paired.hubUrl)
return paired
}
/// Selects another paired hub, rebuilding the session for it.
func switchHub(to hub: String) {
guard registry.setActiveHub(hub) else { return }
if case .paired(let current) = state, current == hub, session != nil {
return
}
activate(hub: hub)
}
/// Unpairs a hub: credentials deleted, registry entry dropped. When it
/// was the active hub, the next registered hub takes over (or the app
/// falls back to the pairing flow).
func signOut(hub: String) {
let wasActive = state == .paired(activeHub: hub)
if wasActive {
session?.shutdown()
session = nil
}
let nextActive = pairingService.unpair(hubUrl: hub)
hubs = registry.hubs
guard wasActive else { return }
if let nextActive {
activate(hub: nextActive)
} else {
state = .unpaired
}
}
// MARK: - Deep links (hapicompanion://bind)
/// Routes a parsed bind link: already-paired hubs just switch (with a
/// notice), everything else goes through the confirm sheet — which covers
/// both the initial pairing and the add-another-hub case.
/// Returns `false` when the link's hub URL is unusable.
@discardableResult
func handleBindLink(_ link: BindLink) -> Bool {
guard let normalized = HubURLNormalization.normalize(link.hubUrl) else {
return false
}
if registry.hubs.contains(normalized), hasCredentials(for: normalized) {
// Re-pairing with a *rotated* token still requires an explicit
// sign-out first; silently replacing stored credentials from any
// scanned link would be an easy way to hijack a pairing.
switchHub(to: normalized)
infoNotice = "Already paired with \(HubDisplay.host(normalized)) — switched to it."
return true
}
// The confirm sheet is presented from the root; make room for it.
showAddHub = false
pendingPairing = PendingPairing(
hubUrl: link.hubUrl,
accessToken: link.accessToken,
source: .deepLink
)
return true
}
// MARK: - App lifecycle
/// Forwarded from the scene: foreground starts/resumes the active
/// session's SSE, background suspends it.
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .active:
isForeground = true
session?.enterForeground()
case .background:
isForeground = false
session?.enterBackground()
case .inactive:
break // transitional; no connection changes
@unknown default:
break
}
}
// MARK: - Internals
/// Builds (and starts, when foregrounded) the session for `hub`,
/// replacing any previous one.
private func activate(hub: String) {
session?.shutdown()
session = nil
guard let newSession = HubSession(
hubUrl: hub,
credentialStore: credentialStore,
performer: performer
) else {
// Unreachable for registry-normalized origins; fail closed.
state = .unpaired
return
}
newSession.onTerminalAuthFailure = { [weak self] in
self?.handleTerminalAuthFailure(for: hub)
}
session = newSession
state = .paired(activeHub: hub)
hubs = registry.hubs
if isForeground {
newSession.enterForeground()
}
}
/// The hub rejected the stored access token (`POST /api/auth` → 401):
/// per the auth contract that is terminal — the token was rotated or
/// revoked, so the dead credentials are removed and the UI drops to
/// pairing (or the next hub) with a banner.
private func handleTerminalAuthFailure(for hub: String) {
session = nil // the session shut itself down before calling back
authFailureNotice = hub
let nextActive = pairingService.unpair(hubUrl: hub)
hubs = registry.hubs
if let nextActive {
activate(hub: nextActive)
} else {
state = .unpaired
}
}
private func hasCredentials(for hub: String) -> Bool {
(try? credentialStore.credentials(forHub: hub)) != nil
}
}
/// Presentation helpers shared by the pairing and home screens.
enum HubDisplay {
/// `https://hub.example.com:8005` → `hub.example.com:8005` (the scheme is
/// noise in tight UI; the full origin stays available for detail rows).
static func host(_ origin: String) -> String {
guard let components = URLComponents(string: origin), let host = components.host else {
return origin
}
if let port = components.port {
return "\(host):\(port)"
}
return host
}
/// `tok_9f8abc123:default` → `tok_…ault`; never reveals more than the
/// edges, and short tokens mask entirely.
static func maskedToken(_ token: String) -> String {
guard token.count > 10 else {
return String(repeating: "•", count: max(4, token.count))
}
return "\(token.prefix(4))…\(token.suffix(4))"
}
}