Files
hapi/ios/Hapi/Features/Pairing/ManualEntryView.swift
T
weishu 3363d1c75b feat(ios): pairing flow, deep link, app session wiring (A-M1d)
- HapiProtocol/Pairing/BindLink: parses the companion deeplink
  (hapicompanion://bind?hub=&code=) and the web direct-access QR
  (?hub=&token=) with URLSearchParams form-decoding semantics, in
  lockstep with the Android port (tests mirror BindLinkTest.kt).
- HapiClient/Auth/HubPairingService: normalize -> GET /health
  (reachability + protocolVersion) -> POST /api/auth -> persist
  Keychain + registry + active hub; unpair with fallback. Covered by
  PairingLogicTests through the HTTPPerforming seam.
- App layer: AppModel (@Observable @MainActor pairing state machine:
  restore, pair, switch, sign out, deep-link routing, scenePhase,
  terminal-auth-failure banner) + HubSession (per-active-hub APIClient/
  AuthManager/global SSEClient with suspend-resume and a connection
  state for the UI; store routing is TODO(M2)).
- Pairing UI: welcome flow, VisionKit QR scanner (with Simulator/
  permission fallbacks), manual entry (paste-friendly), shared confirm
  sheet with per-PairingFailure error states.
- HapiApp routes hapicompanion:// through AppModel (paired hubs switch
  with a notice, never log the token); RootView switches unpaired/
  paired and hosts the deep-link confirm sheet; HomePlaceholderView
  shows hub, connection dot, hub switcher (M2a replaces it with the
  session list).
- Info.plist: NSCameraUsageDescription; README: pairing guide + manual
  test pass.
2026-08-17 15:58:17 +08:00

99 lines
3.6 KiB
Swift

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
)
}
}