mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
- 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.
99 lines
3.6 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|