mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-28 23:01:34 +00:00
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:
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user