mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices. Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
317 lines
12 KiB
Swift
317 lines
12 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
/// A pairing that awaits user confirmation — from a deep link. Drives the
|
|
/// confirm sheet.
|
|
struct PendingPairing: Identifiable {
|
|
let id = UUID()
|
|
/// Hub URL as carried by the link (normalized during `pair`).
|
|
let hubUrl: String
|
|
let accessToken: String
|
|
}
|
|
|
|
/// 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?
|
|
/// Session targeted by a notification tap (Android
|
|
/// `pendingOpenSessionId`): the home screen consumes it into its
|
|
/// navigation path against the active hub (switched to the owner first,
|
|
/// see ``openNotificationTarget(sessionId:hubUrl:)``).
|
|
var pendingOpenSessionId: 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
|
|
@ObservationIgnored private var activationTask: Task<Void, Never>?
|
|
@ObservationIgnored private var activationToken = UUID()
|
|
|
|
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()
|
|
// Push wiring (P3): the coordinator is a process singleton (UIKit
|
|
// delegates outlive any model); it pulls navigation + suppression
|
|
// state through these seams instead of holding the model.
|
|
PushCoordinator.shared.configure(PushCoordinator.Environment(
|
|
registry: registry,
|
|
credentialStore: credentialStore,
|
|
performer: performer,
|
|
openChatSessionId: { [weak self] in self?.session?.openChatSessionId },
|
|
activeHubKnowsSession: { [weak self] sessionId in
|
|
self?.session?.sessionStore.sessions.contains { $0.id == sessionId } ?? false
|
|
},
|
|
openSession: { [weak self] sessionId, hubUrl in
|
|
self?.openNotificationTarget(sessionId: sessionId, hubUrl: hubUrl)
|
|
}
|
|
))
|
|
}
|
|
|
|
// 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)
|
|
// Android timing: the notification-permission prompt fires only once
|
|
// a hub is actually paired — then this hub registers for push.
|
|
PushCoordinator.shared.hubPaired(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
|
|
}
|
|
// Best-effort push unregister, ordered before the credential wipe
|
|
// below (the coordinator snapshots the record synchronously, so the
|
|
// detached DELETE cannot lose the race).
|
|
PushCoordinator.shared.hubWillSignOut(hub)
|
|
let nextActive = pairingService.unpair(hubUrl: hub)
|
|
hubs = registry.hubs
|
|
guard wasActive else { return }
|
|
if let nextActive {
|
|
activate(hub: nextActive)
|
|
} else {
|
|
state = .unpaired
|
|
}
|
|
}
|
|
|
|
/// Notification tap: a located owning hub becomes active first, so the
|
|
/// new home screen consumes the target (`HomeView` is keyed by hub).
|
|
func openNotificationTarget(sessionId: String, hubUrl: String?) {
|
|
if let hubUrl, registry.hubs.contains(hubUrl), hasCredentials(for: hubUrl) {
|
|
switchHub(to: hubUrl)
|
|
}
|
|
pendingOpenSessionId = sessionId
|
|
}
|
|
|
|
// 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 = String(
|
|
format: String(localized: "Already paired with %@ — switched to it."),
|
|
HubDisplay.host(normalized)
|
|
)
|
|
return true
|
|
}
|
|
// The confirm sheet is presented from the root; make room for it.
|
|
showAddHub = false
|
|
pendingPairing = PendingPairing(
|
|
hubUrl: link.hubUrl,
|
|
accessToken: link.accessToken
|
|
)
|
|
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()
|
|
// Re-registers every hub when the APNs token arrives — the
|
|
// cheap-upsert healing pass (Android registrar `start()`).
|
|
PushCoordinator.shared.appDidBecomeActive(isPaired: state != .unpaired)
|
|
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) {
|
|
let previousStore = session?.lastSeenStore
|
|
session?.shutdown()
|
|
session = nil
|
|
let token = UUID()
|
|
activationToken = token
|
|
if previousStore?.isPersisting == true || activationTask != nil {
|
|
let previousActivation = activationTask
|
|
activationTask = Task { [weak self] in
|
|
await previousActivation?.value
|
|
await previousStore?.flushPersistence()
|
|
guard let self, self.activationToken == token else { return }
|
|
self.activationTask = nil
|
|
guard self.registry.activeHub == hub else { return }
|
|
self.buildSession(hub: hub)
|
|
}
|
|
return
|
|
}
|
|
buildSession(hub: hub)
|
|
}
|
|
|
|
private func buildSession(hub: String) {
|
|
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
|
|
// No JWT left to authenticate a push unregister; just drop the hub
|
|
// from the status row (the hub prunes dead tokens itself).
|
|
PushCoordinator.shared.hubRemoved(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))"
|
|
}
|
|
}
|