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

40 lines
1.2 KiB
Swift

import HapiProtocol
import OSLog
import SwiftUI
@main
struct HapiApp: App {
@State private var model = AppModel()
@Environment(\.scenePhase) private var scenePhase
private let log = Logger(subsystem: "run.hapi.companion", category: "app")
var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.onOpenURL { url in
handleIncomingURL(url)
}
}
.onChange(of: scenePhase, initial: true) { _, phase in
model.handleScenePhase(phase)
}
}
/// `hapicompanion://bind?hub=<url>&code=<accessToken>` — parse and route
/// through `AppModel`: unpaired → pairing confirm; already paired to that
/// hub → switch with a notice; a different hub → add-hub confirm.
///
/// Never log the URL itself: the query carries the access token.
private func handleIncomingURL(_ url: URL) {
guard let link = BindLink.parse(url.absoluteString) else {
log.warning("Ignoring malformed URL (scheme: \(url.scheme ?? "none", privacy: .public))")
return
}
if !model.handleBindLink(link) {
log.warning("Bind link carried an unusable hub URL")
}
}
}