87 lines
2.8 KiB
Swift
87 lines
2.8 KiB
Swift
// 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<Bool, Never>()
|
|
var lifecycleTransitions: AnyPublisher<Bool, Never> {
|
|
_lifecycleTransitions.eraseToAnyPublisher()
|
|
}
|
|
|
|
private init() {
|
|
#if DEBUG
|
|
// Test-only launch-argument overrides (gated: never active in Release).
|
|
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
|
|
}
|
|
#endif
|
|
|
|
// Try loading persisted credential on launch
|
|
credential = try? Keychain.shared.load(key: "piremote.credential")
|
|
|
|
#if DEBUG
|
|
// 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()
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
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: 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
|
|
// N-2: after successful Face-ID auth the first emission fired when
|
|
// isLocked=true (so MainTerminalView skipped the reconnect). Re-emit
|
|
// now that isLocked=false so the connection actually resumes.
|
|
if ok { _lifecycleTransitions.send(false) }
|
|
}
|
|
}
|