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.
This commit is contained in:
weishu
2026-08-17 15:58:17 +08:00
parent 9d2ef4e01c
commit 3363d1c75b
16 changed files with 1951 additions and 20 deletions
@@ -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)
}
}
@@ -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
)
}
}
@@ -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."
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
}
}
+24 -4
View File
@@ -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=<url>&code=<accessToken>).
// 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=<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")
}
}
}
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>HAPI uses the camera to scan your hub's pairing QR code.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
+258
View File
@@ -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))"
}
}
+151
View File
@@ -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<Void, Never>?
@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?()
}
}
+51 -12
View File
@@ -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()
))
}
@@ -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
}
}
@@ -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=<url>&code=<accessToken>`, and
/// 2. the web direct-access URL `https://<web>/?hub=<url>&token=<accessToken>`
/// (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)://<web>/...?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=<url>&code=<accessToken>` — 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)://<web host>/...?hub=<url>&token=<accessToken>` — 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[..<separator]
rawValue = pair[pair.index(after: separator)...]
} else {
rawKey = pair
rawValue = ""
}
guard let key = decodeFormComponent(String(rawKey)),
let value = decodeFormComponent(String(rawValue)) else {
return nil
}
if result[key] == nil {
result[key] = value
}
}
return result
}
/// One form-encoded component: `+` means space, then percent-decoding.
/// (`%2B` still decodes to a literal `+` — the replacement happens on the
/// still-encoded text.) `removingPercentEncoding` answers `nil` for
/// invalid escape sequences, matching the Android port's null-on-invalid.
private static func decodeFormComponent(_ component: String) -> 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
}
}
@@ -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: "<html>captive portal</html>")
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)
}
}
@@ -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)
}
@@ -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)
}
}
+63 -4
View File
@@ -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://<web>/?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.