mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +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.
259 lines
9.2 KiB
Swift
259 lines
9.2 KiB
Swift
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))"
|
|
}
|
|
}
|