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