// AppState.swift — global app state, credential lifecycle import Combine import SwiftUI @MainActor final class AppState: ObservableObject { static let shared = AppState() @Published var credential: SidecarCredential? = nil @Published var isLocked = false @Published var lastForegroundedAt: Date = Date() // T-2.10: background/foreground lifecycle publisher // true = app entered background // false = app returned to foreground private let _lifecycleTransitions = PassthroughSubject() var lifecycleTransitions: AnyPublisher { _lifecycleTransitions.eraseToAnyPublisher() } private init() { // Test-only overrides let args = ProcessInfo.processInfo.arguments if args.contains("--reset-state") { Keychain.shared.delete(key: "piremote.credential") UserDefaults.standard.removeObject(forKey: "faceid.enabled") } if args.contains("--enable-faceid") { UserDefaults.standard.set(true, forKey: "faceid.enabled") } if args.contains("--force-lock") { isLocked = true } // Try loading persisted credential on launch credential = try? Keychain.shared.load(key: "piremote.credential") // T-2.10: inject stub credential for UI tests that need MainTerminalView if args.contains("--uitest-with-stub-connection") { credential = SidecarCredential( sidecarId: "stub", host: "127.0.0.1", port: 19991, bearerToken: "stub-token", tlsFingerprint: "stub", sidecarName: "stub", pairedAt: Date() ) } } func didPair(credential: SidecarCredential) { self.credential = credential try? Keychain.shared.save(credential, key: "piremote.credential") } func unpair() { credential = nil Keychain.shared.delete(key: "piremote.credential") } // MARK: - Face ID gate func appDidBackground() { lastForegroundedAt = Date() _lifecycleTransitions.send(true) // T-2.10 } func appWillForeground() async { _lifecycleTransitions.send(false) // T-2.10: always emit before Face-ID gate let elapsed = Date().timeIntervalSince(lastForegroundedAt) guard elapsed > 60 else { return } // within 60s → no re-auth isLocked = true let ok = await FaceIDGate.authenticate() isLocked = !ok } }