From 3363d1c75b5bb5aba3b13c8bedd95cb02efd03a0 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 15:58:17 +0800 Subject: [PATCH] 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. --- .../Features/Home/HomePlaceholderView.swift | 142 ++++++++++ .../Features/Pairing/ManualEntryView.swift | 98 +++++++ .../Features/Pairing/PairingConfirmView.swift | 152 +++++++++++ .../Features/Pairing/PairingFlowView.swift | 127 +++++++++ ios/Hapi/Features/Pairing/QRScannerView.swift | 191 +++++++++++++ ios/Hapi/HapiApp.swift | 28 +- ios/Hapi/Info.plist | 2 + ios/Hapi/Models/AppModel.swift | 258 ++++++++++++++++++ ios/Hapi/Models/HubSession.swift | 151 ++++++++++ ios/Hapi/RootView.swift | 63 ++++- .../HapiClient/Auth/HubPairingService.swift | 154 +++++++++++ .../HapiProtocol/Pairing/BindLink.swift | 156 +++++++++++ .../HapiClientTests/PairingLogicTests.swift | 223 +++++++++++++++ .../Tests/HapiClientTests/TestSupport.swift | 10 + .../HapiProtocolTests/BindLinkTests.swift | 149 ++++++++++ ios/README.md | 67 ++++- 16 files changed, 1951 insertions(+), 20 deletions(-) create mode 100644 ios/Hapi/Features/Home/HomePlaceholderView.swift create mode 100644 ios/Hapi/Features/Pairing/ManualEntryView.swift create mode 100644 ios/Hapi/Features/Pairing/PairingConfirmView.swift create mode 100644 ios/Hapi/Features/Pairing/PairingFlowView.swift create mode 100644 ios/Hapi/Features/Pairing/QRScannerView.swift create mode 100644 ios/Hapi/Models/AppModel.swift create mode 100644 ios/Hapi/Models/HubSession.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiClient/Auth/HubPairingService.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Pairing/BindLink.swift create mode 100644 ios/Packages/HapiKit/Tests/HapiClientTests/PairingLogicTests.swift create mode 100644 ios/Packages/HapiKit/Tests/HapiProtocolTests/BindLinkTests.swift diff --git a/ios/Hapi/Features/Home/HomePlaceholderView.swift b/ios/Hapi/Features/Home/HomePlaceholderView.swift new file mode 100644 index 00000000..15c59edc --- /dev/null +++ b/ios/Hapi/Features/Home/HomePlaceholderView.swift @@ -0,0 +1,142 @@ +import HapiClient +import SwiftUI + +/// Post-pairing placeholder: shows the active hub with its live global-SSE +/// connection state and the hub switcher (switch / add / sign out). The +/// session list replaces the body content in M2. +struct HomePlaceholderView: View { + let session: HubSession + + @Environment(AppModel.self) private var model + @State private var confirmSignOut = false + + var body: some View { + @Bindable var model = model + NavigationStack { + VStack(spacing: 0) { + if let failedHub = model.authFailureNotice { + authFailureBanner(failedHub: failedHub) + } + // TODO(M2a): replace HomePlaceholderView with SessionListView. + ContentUnavailableView { + Label("Paired with \(HubDisplay.host(session.hubUrl))", systemImage: "checkmark.seal") + } description: { + Text("Sessions arrive with M2.") + } + } + .navigationTitle(HubDisplay.host(session.hubUrl)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + connectionIndicator + } + ToolbarItem(placement: .topBarTrailing) { + hubMenu + } + } + .confirmationDialog( + "Sign out of \(HubDisplay.host(session.hubUrl))?", + isPresented: $confirmSignOut, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + model.signOut(hub: session.hubUrl) + } + } message: { + Text("Removes the stored access token for this hub. " + + "Pair again to reconnect.") + } + } + .sheet(isPresented: $model.showAddHub) { + PairingFlowView(context: .addHub) + } + } + + // MARK: - Connection state + + private var connectionIndicator: some View { + HStack(spacing: 6) { + Circle() + .fill(connectionColor) + .frame(width: 8, height: 8) + Text(connectionLabel) + .font(.footnote) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Connection: \(connectionLabel)") + } + + private var connectionColor: Color { + switch session.connectionState { + case .connected: .green + case .connecting, .backoff: .orange + case .idle, .suspended: .gray + } + } + + private var connectionLabel: String { + switch session.connectionState { + case .connected: "Live" + case .connecting: "Connecting…" + case .backoff: "Reconnecting…" + case .suspended: "Paused" + case .idle: "Offline" + } + } + + // MARK: - Hub switcher + + private var hubMenu: some View { + Menu { + Section("Hubs") { + ForEach(model.hubs, id: \.self) { hub in + Button { + model.switchHub(to: hub) + } label: { + if hub == session.hubUrl { + Label(HubDisplay.host(hub), systemImage: "checkmark") + } else { + Text(HubDisplay.host(hub)) + } + } + } + } + Button { + model.showAddHub = true + } label: { + Label("Add Hub…", systemImage: "plus") + } + Button(role: .destructive) { + confirmSignOut = true + } label: { + Label("Sign Out…", systemImage: "rectangle.portrait.and.arrow.right") + } + } label: { + Label("Hubs", systemImage: "server.rack") + } + } + + private func authFailureBanner(failedHub: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("\(HubDisplay.host(failedHub)) rejected its stored " + + "credentials and was signed out. Pair it again from the hub " + + "menu.") + .font(.footnote) + Spacer(minLength: 0) + Button { + model.authFailureNotice = nil + } label: { + Image(systemName: "xmark") + .font(.footnote.bold()) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + .padding(.top, 8) + } +} diff --git a/ios/Hapi/Features/Pairing/ManualEntryView.swift b/ios/Hapi/Features/Pairing/ManualEntryView.swift new file mode 100644 index 00000000..46d4c30c --- /dev/null +++ b/ios/Hapi/Features/Pairing/ManualEntryView.swift @@ -0,0 +1,98 @@ +import HapiProtocol +import SwiftUI + +/// Manual pairing path for hubs without `--relay` (nothing to scan): type or +/// paste the hub URL and the access token the hub prints at startup, then +/// continue into the shared confirm step. +struct ManualEntryView: View { + @Environment(\.dismiss) private var dismiss + @State private var hubUrl = "" + @State private var accessToken = "" + @State private var pending: PendingPairing? + + private var canContinue: Bool { + !hubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("http://192.168.1.20:3006", text: $hubUrl) + .keyboardType(.URL) + .textContentType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Hub URL") + } footer: { + Text("The address the hub prints at startup — a LAN " + + "address like http://192.168.1.20:3006, or the " + + "public tunnel URL when running with --relay.") + } + + Section { + TextField("Access token", text: $accessToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .monospaced() + } header: { + Text("Access token") + } footer: { + Text("Printed by the hub at startup, and shown in the web " + + "app under Settings → Companion Pairing. Pasting a " + + "full pairing link into either field also works.") + } + + Section { + Button("Continue") { + continueToConfirm() + } + .frame(maxWidth: .infinity) + .disabled(!canContinue && parsePastedLink() == nil) + } + } + .navigationTitle("Enter Hub Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .navigationDestination(item: $pending) { pending in + PairingConfirmView(pending: pending) + } + } + } + + /// Convenience: a whole pairing link pasted into either field carries + /// both values at once. + private func parsePastedLink() -> BindLink? { + BindLink.parse(hubUrl) ?? BindLink.parse(accessToken) + } + + private func continueToConfirm() { + if let link = parsePastedLink() { + pending = PendingPairing( + hubUrl: link.hubUrl, + accessToken: link.accessToken, + source: .manual + ) + return + } + var address = hubUrl.trimmingCharacters(in: .whitespacesAndNewlines) + // Typing the scheme on a phone is annoying; local hubs serve plain + // HTTP, and the confirm step shows the resulting URL before pairing. + if !address.isEmpty, !address.contains("://") { + address = "http://\(address)" + } + pending = PendingPairing( + hubUrl: address, + accessToken: accessToken.trimmingCharacters(in: .whitespacesAndNewlines), + source: .manual + ) + } +} diff --git a/ios/Hapi/Features/Pairing/PairingConfirmView.swift b/ios/Hapi/Features/Pairing/PairingConfirmView.swift new file mode 100644 index 00000000..7077d717 --- /dev/null +++ b/ios/Hapi/Features/Pairing/PairingConfirmView.swift @@ -0,0 +1,152 @@ +import HapiClient +import SwiftUI + +/// Final confirmation step shared by every pairing path (deep link, QR scan, +/// manual entry): shows what is about to be paired, runs `AppModel.pair` +/// with progress, and renders ``PairingErrorView`` states on failure. +/// +/// On success `AppModel` clears all pairing presentation state and flips to +/// `.paired`, which dismisses this view's container — nothing to do here. +struct PairingConfirmView: View { + let pending: PendingPairing + + @Environment(AppModel.self) private var model + @State private var isPairing = false + @State private var failure: PairingFailure? + + private var isAddingAnotherHub: Bool { + if case .paired = model.state { return true } + return false + } + + var body: some View { + Form { + Section { + LabeledContent("Hub") { + Text(pending.hubUrl) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + LabeledContent("Access token") { + Text(HubDisplay.maskedToken(pending.accessToken)) + .monospaced() + } + } footer: { + Text(isAddingAnotherHub + ? "This hub will be added to your paired hubs and become active." + : "The app checks the hub is reachable, then exchanges the token for a session.") + } + + if let failure { + Section { + PairingErrorView(failure: failure) + } + } + + Section { + Button { + pairNow() + } label: { + if isPairing { + HStack(spacing: 8) { + ProgressView() + Text("Pairing…") + } + .frame(maxWidth: .infinity) + } else { + Text(failure == nil ? "Pair" : "Try Again") + .frame(maxWidth: .infinity) + } + } + .disabled(isPairing) + } + } + .navigationTitle(isAddingAnotherHub ? "Add Hub" : "Pair") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(isPairing) + } + + private func pairNow() { + guard !isPairing else { return } + isPairing = true + failure = nil + Task { + do { + try await model.pair(hubUrl: pending.hubUrl, accessToken: pending.accessToken) + // Success: AppModel flipped to .paired and closed the pairing + // surfaces; this view is on its way out. + } catch let pairingFailure as PairingFailure { + failure = pairingFailure + } catch { + failure = .unreachable + } + isPairing = false + } + } +} + +/// The pairing screen's error states, one presentation per +/// ``PairingFailure`` case. +struct PairingErrorView: View { + let failure: PairingFailure + + var body: some View { + Label { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: icon) + .foregroundStyle(.red) + } + } + + private var icon: String { + switch failure { + case .invalidHubURL: "questionmark.circle" + case .unreachable: "wifi.exclamationmark" + case .protocolMismatch: "arrow.triangle.2.circlepath" + case .invalidAccessToken: "key" + case .hubError: "exclamationmark.triangle" + case .storageFailure: "externaldrive.badge.xmark" + } + } + + private var title: String { + switch failure { + case .invalidHubURL: "Invalid hub URL" + case .unreachable: "Hub unreachable" + case .protocolMismatch: "Version mismatch" + case .invalidAccessToken: "Token rejected" + case .hubError: "Hub error" + case .storageFailure: "Could not save" + } + } + + private var message: String { + switch failure { + case .invalidHubURL: + "Enter the hub's full address, e.g. http://192.168.1.20:3006." + case .unreachable: + "No HAPI hub answered at this address. Check that the hub is " + + "running and that this device can reach it (same network, " + + "or the relay tunnel is up)." + case .protocolMismatch(let hubVersion, let supportedVersion): + "The hub speaks protocol v\(hubVersion), this app supports " + + "v\(supportedVersion). Update the older side, then try again." + case .invalidAccessToken: + "The hub rejected this access token — it may have been rotated. " + + "Get a fresh pairing code from the hub and try again." + case .hubError(let status): + "The hub answered unexpectedly (HTTP \(status)). Try again in a " + + "moment." + case .storageFailure: + "Pairing succeeded but the credentials could not be stored on " + + "this device. Try again." + } + } +} diff --git a/ios/Hapi/Features/Pairing/PairingFlowView.swift b/ios/Hapi/Features/Pairing/PairingFlowView.swift new file mode 100644 index 00000000..1774eb5c --- /dev/null +++ b/ios/Hapi/Features/Pairing/PairingFlowView.swift @@ -0,0 +1,127 @@ +import HapiClient +import SwiftUI + +/// Entry to pairing: a short self-hosted explanation plus the two ways in — +/// scan the hub's QR code, or type hub URL + access token. Used as the app +/// root while unpaired (`context: .initial`) and as the add-hub sheet from +/// the home screen (`context: .addHub`). +struct PairingFlowView: View { + enum Context { + /// Root screen while nothing is paired. + case initial + /// Presented as a sheet to pair an additional hub. + case addHub + } + + var context: Context = .initial + + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + @State private var showScanner = false + @State private var showManualEntry = false + + private static let docsURL = URL(string: "https://app.hapi.run/docs/") + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + if context == .initial, let failedHub = model.authFailureNotice { + authFailureBanner(failedHub: failedHub) + } + + VStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 56)) + .foregroundStyle(.tint) + .padding(.top, context == .initial ? 48 : 24) + Text(context == .initial ? "Pair with your hub" : "Add another hub") + .font(.title.bold()) + Text( + "HAPI is self-hosted: your agent sessions run on your " + + "own machine, served by a hub you operate. Start the " + + "hub on your computer, then pair this app with the " + + "QR code it prints — or enter the hub URL and access " + + "token by hand." + ) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if let docsURL = Self.docsURL { + Link("Setup guide", destination: docsURL) + .font(.subheadline) + } + } + .padding(.horizontal, 24) + + VStack(spacing: 12) { + Button { + showScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button { + showManualEntry = true + } label: { + Label("Enter Manually", systemImage: "keyboard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(.horizontal, 24) + } + .frame(maxWidth: 480) + .frame(maxWidth: .infinity) + } + .navigationTitle(context == .addHub ? "Add Hub" : "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if context == .addHub { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + .sheet(isPresented: $showScanner) { + QRScannerView() + } + .sheet(isPresented: $showManualEntry) { + ManualEntryView() + } + } + } + + private func authFailureBanner(failedHub: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 4) { + Text("Signed out of \(HubDisplay.host(failedHub))") + .font(.subheadline.bold()) + Text("The hub rejected the stored credentials — the access " + + "token was probably rotated. Pair again to reconnect.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button { + model.authFailureNotice = nil + } label: { + Image(systemName: "xmark") + .font(.footnote.bold()) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + .padding(.top, 8) + } +} diff --git a/ios/Hapi/Features/Pairing/QRScannerView.swift b/ios/Hapi/Features/Pairing/QRScannerView.swift new file mode 100644 index 00000000..edec1b10 --- /dev/null +++ b/ios/Hapi/Features/Pairing/QRScannerView.swift @@ -0,0 +1,191 @@ +import HapiProtocol +import SwiftUI +import UIKit +import Vision +import VisionKit + +/// QR scanning step: a VisionKit live scanner that feeds every recognized +/// string through ``BindLink/parse(_:)`` (so both the companion deeplink QR +/// and the web direct-access QR pair) and pushes the shared confirm step on +/// success. Falls back to guidance when scanning is unsupported (Simulator, +/// no camera) or camera access is denied. +struct QRScannerView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @State private var scanned: PendingPairing? + @State private var cameraDenied = false + @State private var sawForeignCode = false + + var body: some View { + NavigationStack { + scannerContent + .navigationTitle("Scan to Pair") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .navigationDestination(item: $scanned) { pending in + PairingConfirmView(pending: pending) + } + } + } + + @ViewBuilder + private var scannerContent: some View { + if DataScannerViewController.isSupported { + DataScannerRepresentable(isActive: scanned == nil, onScan: handleScannedString) + .ignoresSafeArea(edges: .bottom) + .overlay(alignment: .bottom) { + hintBar + } + .overlay { + if cameraDenied { + cameraDeniedView + } + } + .task { + // Also triggers the camera permission prompt on first use. + cameraDenied = !(await DataScannerViewController.isAvailable) + } + } else { + ContentUnavailableView { + Label("Scanning unavailable", systemImage: "camera") + } description: { + Text("This device cannot scan QR codes (for example the " + + "Simulator). Go back and use manual entry instead.") + } actions: { + Button("Close") { + dismiss() + } + .buttonStyle(.bordered) + } + } + } + + private var hintBar: some View { + Text(sawForeignCode + ? "That code is not a HAPI pairing QR — scan one printed by the hub." + : "Point the camera at the pairing QR from the hub terminal or the " + + "web app's Companion Pairing settings.") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(sawForeignCode ? Color.orange : Color.secondary) + .padding(12) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + } + + private var cameraDeniedView: some View { + ContentUnavailableView { + Label("Camera access denied", systemImage: "video.slash") + } description: { + Text("Allow camera access in Settings to scan the pairing QR, " + + "or go back and use manual entry.") + } actions: { + if let settingsURL = URL(string: UIApplication.openSettingsURLString) { + Button("Open Settings") { + openURL(settingsURL) + } + .buttonStyle(.borderedProminent) + } + } + .background(.background) + } + + /// Returns whether the payload was accepted (stops further scanning). + private func handleScannedString(_ raw: String) -> Bool { + guard scanned == nil else { return true } + guard let link = BindLink.parse(raw) else { + sawForeignCode = true + return false + } + UINotificationFeedbackGenerator().notificationOccurred(.success) + scanned = PendingPairing( + hubUrl: link.hubUrl, + accessToken: link.accessToken, + source: .qrScan + ) + return true + } +} + +/// `DataScannerViewController` wrapped for SwiftUI, restricted to QR codes. +/// `isActive` gates scanning so a successful scan freezes the camera while +/// the confirm step is up and re-arms when the user navigates back. +private struct DataScannerRepresentable: UIViewControllerRepresentable { + var isActive: Bool + var onScan: (String) -> Bool + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + qualityLevel: .balanced, + recognizesMultipleItems: false, + isHighlightingEnabled: true + ) + scanner.delegate = context.coordinator + return scanner + } + + func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { + context.coordinator.onScan = onScan + if isActive { + context.coordinator.hasAccepted = false + if !scanner.isScanning { + // Throws when unavailable (permission denied etc.); the + // SwiftUI layer surfaces that state, so failing quietly here + // is fine. + try? scanner.startScanning() + } + } else if scanner.isScanning { + scanner.stopScanning() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(onScan: onScan) + } + + @MainActor + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + var onScan: (String) -> Bool + var hasAccepted = false + + init(onScan: @escaping (String) -> Bool) { + self.onScan = onScan + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + didAdd addedItems: [RecognizedItem], + allItems: [RecognizedItem] + ) { + process(addedItems, scanner: dataScanner) + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + didUpdate updatedItems: [RecognizedItem], + allItems: [RecognizedItem] + ) { + process(updatedItems, scanner: dataScanner) + } + + private func process(_ items: [RecognizedItem], scanner: DataScannerViewController) { + guard !hasAccepted else { return } + for item in items { + guard case .barcode(let barcode) = item, + let payload = barcode.payloadStringValue else { continue } + if onScan(payload) { + hasAccepted = true + scanner.stopScanning() + return + } + } + } + } +} diff --git a/ios/Hapi/HapiApp.swift b/ios/Hapi/HapiApp.swift index d65b705b..b19f3912 100644 --- a/ios/Hapi/HapiApp.swift +++ b/ios/Hapi/HapiApp.swift @@ -1,19 +1,39 @@ +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 - // M1d wires this to the pairing flow - // (hapicompanion://bind?hub=&code=). - // For M0 the scheme is registered but intentionally ignored. - log.info("Ignoring URL until pairing lands: \(url.absoluteString, privacy: .public)") + handleIncomingURL(url) } } + .onChange(of: scenePhase, initial: true) { _, phase in + model.handleScenePhase(phase) + } + } + + /// `hapicompanion://bind?hub=&code=` — 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") + } } } diff --git a/ios/Hapi/Info.plist b/ios/Hapi/Info.plist index 259f304f..1bb07d8c 100644 --- a/ios/Hapi/Info.plist +++ b/ios/Hapi/Info.plist @@ -2,6 +2,8 @@ + NSCameraUsageDescription + HAPI uses the camera to scan your hub's pairing QR code. CFBundleURLTypes diff --git a/ios/Hapi/Models/AppModel.swift b/ios/Hapi/Models/AppModel.swift new file mode 100644 index 00000000..fdafcd7d --- /dev/null +++ b/ios/Hapi/Models/AppModel.swift @@ -0,0 +1,258 @@ +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))" + } +} diff --git a/ios/Hapi/Models/HubSession.swift b/ios/Hapi/Models/HubSession.swift new file mode 100644 index 00000000..edfe04d1 --- /dev/null +++ b/ios/Hapi/Models/HubSession.swift @@ -0,0 +1,151 @@ +import Foundation +import HapiClient +import HapiProtocol +import Observation + +/// Everything the app holds for the currently active hub: the typed REST +/// client, its auth manager, and the **global** SSE subscription +/// (`scope: .global` — one per app session; per-chat `.session` subscriptions +/// arrive with M2). +/// +/// Lifecycle is driven by `AppModel` from `scenePhase`: the SSE connection +/// starts on the first foreground, suspends in background, and resumes (with +/// the 45 s staleness check) on return. `AppModel` builds a fresh instance per +/// hub switch and calls `shutdown()` on the old one — a `HubSession` never +/// outlives its hub selection. +@MainActor @Observable +final class HubSession { + /// Normalized hub origin (registry / credential key). + let hubUrl: String + let baseURL: URL + let authManager: AuthManager + let api: APIClient + + /// Global SSE connection state, for the UI's connection dot. + private(set) var connectionState: SSEConnectionState = .idle + /// Verdict of the latest SSE handshake (`ok` = replay covered the gap). + private(set) var lastResumeVerdict: ResumeVerdict? + /// From the latest handshake; needed for `POST /api/visibility` (M3b). + private(set) var subscriptionId: String? + + /// Fired once when the hub terminally rejects the stored credentials + /// (access token rotated/revoked). `AppModel` reacts by dropping the hub + /// back to pairing with a banner. + @ObservationIgnored var onTerminalAuthFailure: (@MainActor () -> Void)? + + @ObservationIgnored private var sse: SSEClient? + @ObservationIgnored private var consumeTask: Task? + @ObservationIgnored private var reportedAuthFailure = false + @ObservationIgnored private var isShutDown = false + + init?( + hubUrl: String, + credentialStore: any CredentialStoring, + performer: any HTTPPerforming = URLSessionHTTPPerformer.shared + ) { + guard let baseURL = URL(string: hubUrl) else { return nil } + self.hubUrl = hubUrl + self.baseURL = baseURL + self.authManager = AuthManager( + baseURL: baseURL, + credentialStore: credentialStore, + performer: performer + ) + self.api = APIClient(baseURL: baseURL, authManager: authManager, performer: performer) + } + + // MARK: - Lifecycle (driven by AppModel from scenePhase) + + /// First call starts the global SSE subscription; later calls resume a + /// suspended one (which distrusts sockets silent for 45 s or more). + func enterForeground() { + guard !isShutDown, !reportedAuthFailure else { return } + if let sse { + Task { await sse.resume() } + } else { + startGlobalSSE() + } + } + + /// Parks the SSE retry loop; a live connection is left to the OS. + func enterBackground() { + guard let sse else { return } + Task { await sse.suspend() } + } + + /// Tears everything down. The session is unusable afterwards. + func shutdown() { + isShutDown = true + onTerminalAuthFailure = nil + consumeTask?.cancel() + consumeTask = nil + if let sse { + self.sse = nil + Task { await sse.stop() } + } + connectionState = .idle + } + + // MARK: - Global SSE + + private func startGlobalSSE() { + let auth = authManager + let configuration = SSEClientConfiguration( + baseUrl: baseURL, + tokenProvider: { [weak self] in + do { + return try await auth.validToken() + } catch AuthError.reauthenticationRequired { + // Terminal: only re-pairing recovers. Surface once on the + // main actor; returning nil parks the SSE loop in backoff + // until AppModel shuts this session down. + let session = self // weak capture, may already be gone + Task { @MainActor in + session?.handleTerminalAuthFailure() + } + return nil + } catch { + // Transient (offline, hub restarting): the SSE loop backs + // off and asks again. + return nil + } + }, + scope: .global + ) + let client = SSEClient(configuration: configuration, pathObserver: NWPathObserver()) + sse = client + consumeTask = Task { [weak self] in + let stream = await client.start() + for await event in stream { + guard let self, !Task.isCancelled else { return } + self.handle(event) + } + } + } + + private func handle(_ event: SSEClientEvent) { + switch event { + case .stateChanged(let state): + connectionState = state + case .handshake(let resume, let subscriptionId): + lastResumeVerdict = resume + self.subscriptionId = subscriptionId + // TODO(M2): `resume == .ok` means the replay that follows covers + // every missed event (skip the REST resync); `.gap` triggers a + // full session-list refetch. Routing lands with the M2 stores. + case .event: + // TODO(M2): route SyncEvents into the session-list store and the + // per-session message-window stores. Until those exist the global + // stream is consumed for connection state only. + break + } + } + + private func handleTerminalAuthFailure() { + guard !reportedAuthFailure, !isShutDown else { return } + reportedAuthFailure = true + let callback = onTerminalAuthFailure + shutdown() + callback?() + } +} diff --git a/ios/Hapi/RootView.swift b/ios/Hapi/RootView.swift index 98365033..45c266bc 100644 --- a/ios/Hapi/RootView.swift +++ b/ios/Hapi/RootView.swift @@ -1,26 +1,65 @@ import HapiClient -import HapiProtocol 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 { - NavigationStack { - ContentUnavailableView { - Label("HAPI", systemImage: "antenna.radiowaves.left.and.right") - } description: { - Text("Native companion scaffold (M0). Pairing and sessions arrive in M1.") + @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() + } } - .navigationTitle("HAPI") - .safeAreaInset(edge: .bottom) { - Text("HapiKit \(HapiClientVersion.current) · protocol v\(ProtocolVersion.supported)") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.bottom, 8) + } + .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() + )) } diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Auth/HubPairingService.swift b/ios/Packages/HapiKit/Sources/HapiClient/Auth/HubPairingService.swift new file mode 100644 index 00000000..2364e869 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiClient/Auth/HubPairingService.swift @@ -0,0 +1,154 @@ +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 + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Pairing/BindLink.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Pairing/BindLink.swift new file mode 100644 index 00000000..e3e1598e --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Pairing/BindLink.swift @@ -0,0 +1,156 @@ +import Foundation + +/// Parsed companion pairing link. +/// +/// The hub prints (and renders as QR codes) two pairing links — see +/// `hub/src/startHub.ts` and `docs/api/client-contract/auth.md`: +/// +/// 1. The canonical companion deeplink +/// `hapicompanion://bind?hub=&code=`, and +/// 2. the web direct-access URL `https:///?hub=&token=` +/// (same values, different scheme and param name). A robust scanner accepts +/// both, so pointing the camera at the "wrong" QR still pairs. +/// +/// Both queries are built with `URLSearchParams`, i.e. +/// `application/x-www-form-urlencoded`: `%xx` escapes plus `+` for space. +/// `code`/`token` is the raw CLI access token, possibly suffixed with +/// `:namespace`, so it round-trips percent-encoded (`%3A`). +/// +/// Semantics are kept in lockstep with the Android port +/// (`android/core/protocol/.../pairing/BindLink.kt`): scheme and host match +/// case-insensitively, the first occurrence wins for duplicate params, blank +/// values are rejected, the `hub` value must itself be an http(s) URL, and any +/// malformed input (including invalid percent escapes) parses to `nil`. +/// +/// Lives in `HapiProtocol` (pure Foundation) so it is unit-testable without +/// the app target; the app's `onOpenURL` handler and QR scanner both feed +/// their strings through ``parse(_:)``. +public struct BindLink: Equatable, Sendable { + /// The hub base URL exactly as carried by the link (decoded, not yet + /// normalized — hub-URL normalization is the client layer's job). + public let hubUrl: String + /// The pairing access token, passed verbatim to `POST /api/auth`. + public let accessToken: String + + public init(hubUrl: String, accessToken: String) { + self.hubUrl = hubUrl + self.accessToken = accessToken + } + + /// Scheme of the canonical companion deeplink. + public static let scheme = "hapicompanion" + /// Host of the canonical companion deeplink. + public static let host = "bind" + + /// Parses `raw` into a ``BindLink``. + /// + /// Accepts the companion deeplink form (`hapicompanion://bind?hub=&code=`) + /// and the web direct-access form (`http(s):///...?hub=&token=`). + /// Returns `nil` on any malformed input: unparseable URL, wrong scheme or + /// host, missing/blank `hub` and `code`/`token` parameters, invalid + /// percent escapes, or a `hub` value that is not an http(s) URL. + public static func parse(_ raw: String) -> BindLink? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased() else { + return nil + } + switch scheme { + case Self.scheme: + return parseCompanionDeeplink(components) + case "http", "https": + return parseWebDirectAccessURL(components) + default: + return nil + } + } + + // MARK: - Forms + + /// `hapicompanion://bind?hub=&code=` — the canonical + /// deeplink; the only form that arrives via the registered URL scheme. + private static func parseCompanionDeeplink(_ components: URLComponents) -> BindLink? { + guard let host = components.host?.lowercased(), host == Self.host else { + return nil + } + guard let params = formParameters(of: components), + let hub = params["hub"], !isBlank(hub), + let code = params["code"], !isBlank(code), + isHTTPURL(hub) else { + return nil + } + return BindLink(hubUrl: hub, accessToken: code) + } + + /// `http(s):///...?hub=&token=` — the web + /// direct-access QR (`${officialWebUrl}/?hub=…&token=…`). The web host and + /// path are not pinned: self-hosted deployments serve the app anywhere. + private static func parseWebDirectAccessURL(_ components: URLComponents) -> BindLink? { + guard let host = components.host, !host.isEmpty else { + return nil + } + guard let params = formParameters(of: components), + let hub = params["hub"], !isBlank(hub), + let token = params["token"], !isBlank(token), + isHTTPURL(hub) else { + return nil + } + return BindLink(hubUrl: hub, accessToken: token) + } + + // MARK: - Form decoding (URLSearchParams parity) + + /// Decodes the raw query as `application/x-www-form-urlencoded`. + /// First occurrence wins for duplicate keys. Returns `nil` when the query + /// is absent or any component carries invalid percent escapes. + private static func formParameters(of components: URLComponents) -> [String: String]? { + guard let rawQuery = components.percentEncodedQuery else { return nil } + var result: [String: String] = [:] + for pair in rawQuery.split(separator: "&", omittingEmptySubsequences: false) { + if pair.isEmpty { continue } + let rawKey: Substring + let rawValue: Substring + if let separator = pair.firstIndex(of: "=") { + rawKey = pair[.. String? { + component + .replacingOccurrences(of: "+", with: " ") + .removingPercentEncoding + } + + private static func isBlank(_ value: String) -> Bool { + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// The `hub` value must be an absolute http(s) URL with a host. + private static func isHTTPURL(_ value: String) -> Bool { + guard let components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = components.host, !host.isEmpty else { + return false + } + return true + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/PairingLogicTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/PairingLogicTests.swift new file mode 100644 index 00000000..1adc47f3 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/PairingLogicTests.swift @@ -0,0 +1,223 @@ +import Foundation +import HapiClient +import Testing + +/// The pairing sequence (normalize → health → auth → persist) against the +/// scripted `RecordingPerformer`. The SwiftUI layer on top (`AppModel`) only +/// translates these outcomes into view state. +@Suite("HubPairingService") +struct PairingLogicTests { + private struct World { + let defaults: UserDefaults + let suiteName: String + let registry: HubRegistry + let store: InMemoryCredentialStore + let performer: RecordingPerformer + let service: HubPairingService + + func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + } + } + + private func makeWorld() throws -> World { + let suiteName = "PairingLogicTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + let registry = HubRegistry(defaults: defaults) + let store = InMemoryCredentialStore() + let performer = RecordingPerformer() + let service = HubPairingService( + registry: registry, + credentialStore: store, + performer: performer, + now: { testNow } + ) + return World( + defaults: defaults, + suiteName: suiteName, + registry: registry, + store: store, + performer: performer, + service: service + ) + } + + private func healthJSON(protocolVersion: Int = 1, status: String = "ok") -> String { + "{\"status\":\"\(status)\",\"protocolVersion\":\(protocolVersion),\"capabilities\":{\"workGraph\":true}}" + } + + // MARK: - Success + + @Test func successfulPairPersistsCredentialsAndActivatesHub() async throws { + let world = try makeWorld() + defer { world.tearDown() } + let jwt = freshJWT() + await world.performer.enqueue(status: 200, json: healthJSON()) + await world.performer.enqueue(status: 200, json: authResponseJSON(token: jwt)) + + let paired = try await world.service.pair( + rawHubUrl: " https://Hub.Example.com/ ", + accessToken: "tok_9f8:default" + ) + + #expect(paired.hubUrl == "https://hub.example.com") + #expect(paired.claims?.ns == "default") + let stored = try #require(try world.store.credentials(forHub: "https://hub.example.com")) + #expect(stored.accessToken == "tok_9f8:default") + #expect(stored.jwt == jwt) + #expect(world.registry.hubs == ["https://hub.example.com"]) + #expect(world.registry.activeHub == "https://hub.example.com") + + // Wire order: unauthenticated GET /health, then POST /api/auth with + // the token passed through verbatim (opaque, incl. the namespace). + let requests = await world.performer.requests + #expect(requests.count == 2) + #expect(requests[0].url?.absoluteString == "https://hub.example.com/health") + #expect(requests[0].value(forHTTPHeaderField: "Authorization") == nil) + #expect(requests[1].url?.absoluteString == "https://hub.example.com/api/auth") + let body = try #require(requests[1].httpBody) + #expect(String(data: body, encoding: .utf8) == "{\"accessToken\":\"tok_9f8:default\"}") + } + + @Test func pairingASecondHubMakesItActive() async throws { + let world = try makeWorld() + defer { world.tearDown() } + await world.performer.enqueue(status: 200, json: healthJSON()) + await world.performer.enqueue(status: 200, json: authResponseJSON(token: freshJWT())) + _ = try await world.service.pair(rawHubUrl: "https://one.test", accessToken: "a") + + await world.performer.enqueue(status: 200, json: healthJSON()) + await world.performer.enqueue(status: 200, json: authResponseJSON(token: freshJWT())) + _ = try await world.service.pair(rawHubUrl: "https://two.test", accessToken: "b") + + #expect(world.registry.hubs == ["https://one.test", "https://two.test"]) + #expect(world.registry.activeHub == "https://two.test") + } + + @Test func repairingReplacesTheStoredAccessToken() async throws { + let world = try makeWorld() + defer { world.tearDown() } + try world.store.store(HubCredentials(hubUrl: "https://hub.test", accessToken: "rotated-away")) + world.registry.register("https://hub.test") + await world.performer.enqueue(status: 200, json: healthJSON()) + await world.performer.enqueue(status: 200, json: authResponseJSON(token: freshJWT())) + + _ = try await world.service.pair(rawHubUrl: "https://hub.test", accessToken: "fresh-token") + + let stored = try #require(try world.store.credentials(forHub: "https://hub.test")) + #expect(stored.accessToken == "fresh-token") + #expect(world.registry.hubs == ["https://hub.test"]) + } + + // MARK: - Failure states (the pairing screen's error cases) + + @Test func rejectsAnInvalidHubURLWithoutTouchingTheNetwork() async throws { + let world = try makeWorld() + defer { world.tearDown() } + + let error = await capturedError { + try await world.service.pair(rawHubUrl: "not a url", accessToken: "x") + } + + #expect(error as? PairingFailure == .invalidHubURL) + #expect(await world.performer.requests.isEmpty) + } + + @Test func transportFailureIsUnreachable() async throws { + let world = try makeWorld() + defer { world.tearDown() } + await world.performer.setError(URLError(.cannotConnectToHost)) + + let error = await capturedError { + try await world.service.pair(rawHubUrl: "https://hub.test", accessToken: "x") + } + + #expect(error as? PairingFailure == .unreachable) + } + + @Test func nonHubResponseBodyIsUnreachable() async throws { + let world = try makeWorld() + defer { world.tearDown() } + await world.performer.enqueue(status: 200, json: "captive portal") + + let error = await capturedError { + try await world.service.pair(rawHubUrl: "https://hub.test", accessToken: "x") + } + + #expect(error as? PairingFailure == .unreachable) + } + + @Test func protocolMismatchIsSurfacedWithBothVersions() async throws { + let world = try makeWorld() + defer { world.tearDown() } + await world.performer.enqueue(status: 200, json: healthJSON(protocolVersion: 2)) + + let error = await capturedError { + try await world.service.pair(rawHubUrl: "https://hub.test", accessToken: "x") + } + + #expect(error as? PairingFailure == .protocolMismatch(hubVersion: 2, supportedVersion: 1)) + // Never sends the token to an incompatible hub. + #expect(await world.performer.requests.count == 1) + } + + @Test func badTokenIsInvalidAccessTokenAndPersistsNothing() async throws { + let world = try makeWorld() + defer { world.tearDown() } + await world.performer.enqueue(status: 200, json: healthJSON()) + await world.performer.enqueue(status: 401, json: "{\"error\":\"Invalid access token\"}") + + let error = await capturedError { + try await world.service.pair(rawHubUrl: "https://hub.test", accessToken: "wrong") + } + + #expect(error as? PairingFailure == .invalidAccessToken) + #expect(try world.store.credentials(forHub: "https://hub.test") == nil) + #expect(world.registry.hubs.isEmpty) + } + + @Test func unexpectedAuthStatusIsHubError() async throws { + let world = try makeWorld() + defer { world.tearDown() } + await world.performer.enqueue(status: 200, json: healthJSON()) + await world.performer.enqueue(status: 500, json: "{\"error\":\"boom\"}") + + let error = await capturedError { + try await world.service.pair(rawHubUrl: "https://hub.test", accessToken: "x") + } + + #expect(error as? PairingFailure == .hubError(status: 500)) + } + + // MARK: - Unpairing + + @Test func unpairDeletesCredentialsAndFallsBackToNextHub() async throws { + let world = try makeWorld() + defer { world.tearDown() } + for hub in ["https://one.test", "https://two.test"] { + try world.store.store(HubCredentials(hubUrl: hub, accessToken: "t")) + world.registry.register(hub) + } + world.registry.setActiveHub("https://one.test") + + let nextActive = world.service.unpair(hubUrl: "https://one.test") + + #expect(nextActive == "https://two.test") + #expect(try world.store.credentials(forHub: "https://one.test") == nil) + #expect(try world.store.credentials(forHub: "https://two.test") != nil) + #expect(world.registry.hubs == ["https://two.test"]) + } + + @Test func unpairingTheLastHubReturnsNil() async throws { + let world = try makeWorld() + defer { world.tearDown() } + try world.store.store(HubCredentials(hubUrl: "https://one.test", accessToken: "t")) + world.registry.register("https://one.test") + + let nextActive = world.service.unpair(hubUrl: "https://one.test") + + #expect(nextActive == nil) + #expect(world.registry.hubs.isEmpty) + #expect(try world.store.credentials(forHub: "https://one.test") == nil) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/TestSupport.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/TestSupport.swift index 3b02e503..66acba69 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/TestSupport.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/TestSupport.swift @@ -49,11 +49,18 @@ actor RecordingPerformer: HTTPPerforming { private var queue: [Stub] = [] private var fallback = Stub() private var delayNanoseconds: UInt64 = 0 + private var transportError: (any Error & Sendable)? func enqueue(status: Int = 200, json: String = "{}", headers: [String: String] = [:]) { queue.append(Stub(status: status, body: Data(json.utf8), headers: headers)) } + /// Makes every subsequent request fail at the transport level (URLError), + /// as `HTTPPerforming` implementations do for unreachable hosts. + func setError(_ error: any Error & Sendable) { + transportError = error + } + func setFallback(status: Int, json: String) { fallback = Stub(status: status, body: Data(json.utf8)) } @@ -66,6 +73,9 @@ actor RecordingPerformer: HTTPPerforming { func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { requests.append(request) + if let transportError { + throw transportError + } if delayNanoseconds > 0 { try await Task.sleep(nanoseconds: delayNanoseconds) } diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/BindLinkTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/BindLinkTests.swift new file mode 100644 index 00000000..6ed4e896 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/BindLinkTests.swift @@ -0,0 +1,149 @@ +import Foundation +import HapiProtocol +import Testing + +/// Mirrors the Android suite (`android/.../pairing/BindLinkTest.kt`) case for +/// case so the two ports cannot drift, plus the web direct-access form the +/// iOS parser additionally accepts. +@Suite("BindLink parsing") +struct BindLinkTests { + // MARK: - Companion deeplink form + + @Test func parsesAPlainValidLink() { + let link = BindLink.parse("hapicompanion://bind?hub=http://192.168.1.20:3006&code=abc123") + + #expect(link == BindLink(hubUrl: "http://192.168.1.20:3006", accessToken: "abc123")) + } + + @Test func parsesURLEncodedValuesAsTheHubEmitsThem() { + // Exactly what `new URLSearchParams({hub, code}).toString()` produces + // for hub=https://demo.hapi.run and code=tok_9f8:default. + let link = BindLink.parse( + "hapicompanion://bind?hub=https%3A%2F%2Fdemo.hapi.run&code=tok_9f8%3Adefault" + ) + + #expect(link?.hubUrl == "https://demo.hapi.run") + #expect(link?.accessToken == "tok_9f8:default") + } + + @Test func decodesPlusAsSpaceInFormEncodedValues() { + let link = BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=a+b") + + #expect(link?.accessToken == "a b") + } + + @Test func keepsEncodedPlusLiteral() { + let link = BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=a%2Bb") + + #expect(link?.accessToken == "a+b") + } + + @Test func acceptsSchemeAndHostCaseInsensitively() { + let link = BindLink.parse("HAPICOMPANION://BIND?hub=https%3A%2F%2Fh.example.com&code=x") + + #expect(link?.hubUrl == "https://h.example.com") + } + + @Test func ignoresExtraParamsAndKeepsFirstOccurrenceOfDuplicates() { + let link = BindLink.parse( + "hapicompanion://bind?v=1&hub=https%3A%2F%2Fa.example.com&code=first&code=second" + ) + + #expect(link?.hubUrl == "https://a.example.com") + #expect(link?.accessToken == "first") + } + + @Test func returnsNilWhenHubParamIsMissing() { + #expect(BindLink.parse("hapicompanion://bind?code=abc123") == nil) + } + + @Test func returnsNilWhenCodeParamIsMissing() { + #expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com") == nil) + } + + @Test func returnsNilWhenQueryIsAbsent() { + #expect(BindLink.parse("hapicompanion://bind") == nil) + } + + @Test func returnsNilWhenParamsAreBlank() { + #expect(BindLink.parse("hapicompanion://bind?hub=&code=x") == nil) + #expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=") == nil) + } + + @Test func returnsNilForAWrongScheme() { + // https with `code=` is neither a valid deeplink nor a valid web QR + // (the web form carries `token=`). + #expect(BindLink.parse("https://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil) + #expect(BindLink.parse("hapicompanionx://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil) + #expect(BindLink.parse("ftp://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil) + } + + @Test func returnsNilForAWrongHost() { + #expect(BindLink.parse("hapicompanion://pair?hub=https%3A%2F%2Fh.example.com&code=x") == nil) + } + + @Test func returnsNilWhenHubIsNotAnHTTPURL() { + #expect(BindLink.parse("hapicompanion://bind?hub=ftp%3A%2F%2Fh.example.com&code=x") == nil) + #expect(BindLink.parse("hapicompanion://bind?hub=not-a-url&code=x") == nil) + } + + @Test func returnsNilForMalformedInput() { + #expect(BindLink.parse("") == nil) + #expect(BindLink.parse(" ") == nil) + #expect(BindLink.parse("not a uri at all") == nil) + // Scheme-relative (no authority): `bind` is a path, not a host. + #expect(BindLink.parse("hapicompanion:bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil) + } + + @Test func returnsNilOnInvalidPercentEscapes() { + #expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=%zz") == nil) + } + + @Test func trimsSurroundingWhitespace() { + let link = BindLink.parse(" hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=x\n") + + #expect(link?.hubUrl == "https://h.example.com") + #expect(link?.accessToken == "x") + } + + // MARK: - Web direct-access form + + @Test func parsesTheWebDirectAccessQR() { + // Exactly what `${officialWebUrl}/?${new URLSearchParams({hub, token})}` + // produces in hub/src/startHub.ts. + let link = BindLink.parse( + "https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=tok_9f8%3Adefault" + ) + + #expect(link?.hubUrl == "https://demo.hapi.run") + #expect(link?.accessToken == "tok_9f8:default") + } + + @Test func acceptsAnyWebHostAndPath() { + // Self-hosted deployments serve the web app anywhere. + let link = BindLink.parse( + "http://my-server.local:8080/hapi/?hub=http%3A%2F%2F192.168.1.20%3A3006&token=abc" + ) + + #expect(link?.hubUrl == "http://192.168.1.20:3006") + #expect(link?.accessToken == "abc") + } + + @Test func webFormRequiresTokenParamName() { + // `code=` belongs to the deeplink form only. + #expect(BindLink.parse("https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&code=abc") == nil) + } + + @Test func webFormRequiresHubParam() { + #expect(BindLink.parse("https://app.hapi.run/?token=abc") == nil) + } + + @Test func webFormRejectsBlankValues() { + #expect(BindLink.parse("https://app.hapi.run/?hub=&token=abc") == nil) + #expect(BindLink.parse("https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=") == nil) + } + + @Test func webFormRequiresAnHTTPHubValue() { + #expect(BindLink.parse("https://app.hapi.run/?hub=ftp%3A%2F%2Fdemo.hapi.run&token=abc") == nil) + } +} diff --git a/ios/README.md b/ios/README.md index 60f2edd1..f0361060 100644 --- a/ios/README.md +++ b/ios/README.md @@ -33,9 +33,19 @@ ios/ Hapi.xcodeproj/ Hand-rolled minimal project (objectVersion 77). Hapi/ App target sources. This is an Xcode 16 "synchronized folder": add files here and they join the target - without touching project.pbxproj. + without touching project.pbxproj. As of M1d: + Models/ AppModel (pairing state machine, hub + switching, deep-link routing, scene + phase) + HubSession (per-active-hub + APIClient/AuthManager/global SSEClient, + connection state for the UI). + Features/ Pairing/ (welcome, VisionKit QR scan, + manual entry, shared confirm + error + states) and Home/ (post-pairing + placeholder with hub switcher and + connection dot; session list lands M2). Packages/HapiKit/ Local SPM package with the real logic: - HapiProtocol Pure-Foundation protocol layer. As of M1a: + HapiProtocol Pure-Foundation protocol layer. As of M1a+M1d: Models/ wire types mirroring shared/src/schemas.ts (Session, SessionPatch + VersionedValue, AgentState, DecryptedMessage, SessionSummary, @@ -44,10 +54,14 @@ ios/ shared/src/{modes,flavors,copilotModes}.ts Patch/ versioned session-patch application ported from web/src/lib/sessionPatch.ts + Pairing/ BindLink — parses both pairing QR forms + (hapicompanion://bind?hub=&code= and the + web /?hub=&token= URL), form-decoding in + lockstep with the Android port The chat pipeline + message window logic (ported from web/src/chat/**) land in M2, validated against shared/fixtures/**. - HapiClient Transport layer. As of M1b+M1c: + HapiClient Transport layer. As of M1b+M1c+M1d: APIClient typed REST client (Endpoints/*): Bearer auth, 401 -> refresh -> retry-once, {error, code} parsing @@ -63,7 +77,12 @@ ios/ credential store (per-hub records under run.hapi.companion), HubRegistry (multi-hub + active - hub in UserDefaults). + hub in UserDefaults), + HubPairingService (normalize -> + /health + protocolVersion check -> + /api/auth -> persist; unpair with + fallback), tested through the + HTTPPerforming seam. SSE/ actor SSEClient — handshake-gated connect (resume ok/gap surfaced), sticky per-subscription cursor with @@ -100,6 +119,46 @@ every `chat/*.json` input as `[DecryptedMessage]` (+ `AgentState`), and `catalogs/modes.json`; the full pipeline conformance against each fixture's `expected` projection is the M2 gate. +## Pairing (M1d) + +How to pair the app with a hub (`docs/api/client-contract/auth.md` is the +contract; the app accepts multiple hubs and keeps one active): + +- **Local hub, manual entry** — the everyday dev loop: + 1. Start the stack from the repo root: `bun run dev` (or just the hub). The + hub prints its URL and the access token (`CLI_API_TOKEN`, auto-generated + into the hub's `settings.json` on first run). + 2. In the app: *Enter Manually* → hub URL (e.g. `http://192.168.1.20:3006` + — the phone must reach the hub's LAN address, not `localhost`; a typed + address without a scheme gets `http://` prefixed) → paste the token → + *Continue* → *Pair*. + 3. The app checks `GET /health` (reachability + `protocolVersion`), then + exchanges the token via `POST /api/auth` and stores it in the Keychain. +- **QR scan** — start the hub with `--relay`: the terminal prints two QR + codes. The scanner accepts **both** — the companion deeplink + (`hapicompanion://bind?hub=…&code=…`, canonical) and the web direct-access + URL (`https:///?hub=…&token=…`). The web app's Settings → Companion + Pairing screen renders the deeplink QR too. +- **Deep link** — opening a `hapicompanion://bind` link routes through the + same confirm sheet; a link for an already-paired hub just switches to it. +- **Simulator**: camera scanning is unavailable (`DataScannerViewController` + unsupported) — the scanner screen says so; use manual entry. Plain-HTTP LAN + hubs work because `NSAllowsLocalNetworking` stays enabled (ATS default + otherwise). +- **Sign out** (home → hub menu) deletes the stored token for that hub and + falls back to the next paired hub, or to pairing. A hub that terminally + rejects its stored token (rotated/revoked → `POST /api/auth` 401) is signed + out automatically with a banner. + +Manual test pass for the app layer (the pairing sequence itself is covered by +`PairingLogicTests` via injected HTTP fakes; `AppModel`/views are UI-bound): +pair via manual entry against a local hub → kill + relaunch (restores paired +state, SSE reconnects) → background/foreground (connection dot pauses and +resumes) → pair a second hub and switch between them → sign out of both → +scan both `--relay` QR forms → open a `hapicompanion://bind` link from Notes +(unpaired: confirm; paired: "already paired" notice) → rotate +`CLI_API_TOKEN` on the hub and watch the auto sign-out banner. + ## Milestones (track A of the native-clients plan) - **M0** — this scaffold: project, HapiKit package, CI, one passing test.