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

66 lines
2.0 KiB
Swift

import HapiClient
import SwiftUI
/// Switches the app root on pairing state and hosts the presentation that
/// must survive that switch: the deep-link pairing confirm sheet and the
/// "already paired" notice.
struct RootView: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var model = model
Group {
switch model.state {
case .unpaired:
PairingFlowView()
case .paired:
if let session = model.session {
// TODO(M2a): replace HomePlaceholderView with SessionListView.
HomePlaceholderView(session: session)
} else {
// Defensive: .paired always carries a session; fall back
// to pairing rather than a dead screen.
PairingFlowView()
}
}
}
.sheet(item: $model.pendingPairing) { pending in
NavigationStack {
PairingConfirmView(pending: pending)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
model.pendingPairing = nil
}
}
}
}
}
.alert(
"Hub already paired",
isPresented: Binding(
get: { model.infoNotice != nil },
set: { presented in
if !presented {
model.infoNotice = nil
}
}
)
) {
Button("OK") {
model.infoNotice = nil
}
} message: {
Text(model.infoNotice ?? "")
}
}
}
#Preview {
RootView()
.environment(AppModel(
registry: HubRegistry(defaults: UserDefaults(suiteName: "preview") ?? .standard),
credentialStore: InMemoryCredentialStore()
))
}