import Foundation import LocalAuthentication import SwiftUI import Observation /// Face ID 启动锁的运行时控制器(单例)。 /// /// 设计见 `docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md`。 /// 红线对齐(CLAUDE.md §10.2):只用系统 `LocalAuthentication`,不自造任何密码学。 /// /// 单例写法与 `ModelDownloadService.shared` 一致:`@MainActor @Observable`。 /// UI(`AppLockContainer` / `MeView` / `LockScreenView`)只观察本类的 observable 状态, /// 通过 `handleAppear` / `handleScenePhase` / `authenticate` 等方法驱动。 @MainActor @Observable final class AppLock { static let shared = AppLock() /// 后台超过该时长再回前台 → 重锁。 static let gracePeriod: TimeInterval = 60 /// 启用开关持久化 key,与 `MeView` 的 `@AppStorage` 同源。 static let enabledKey = "faceIDLockEnabled" // MARK: - Observable 运行态 /// 当前是否处于锁定(需认证才能进入)。 private(set) var isLocked = false /// 进入任务切换器 / 后台时是否盖隐私遮罩(仅锁开启时为真)。 private(set) var showsPrivacyCover = false /// 设备是否可用生物识别或密码认证(无密码设备为 false)。 private(set) var biometryAvailable = false /// 认证按钮 / 副标题文案:"Face ID" / "Touch ID" / "密码"。 private(set) var biometryLabel = String(appLoc: "密码") // MARK: - 非观察内部态 /// 是否已开启启动锁。读写 UserDefaults(与 MeView 的 @AppStorage 同 key)。 /// 不需要 observable —— UI 侧用 @AppStorage 观察这个 key 的变化。 var enabled: Bool { get { UserDefaults.standard.bool(forKey: Self.enabledKey) } set { UserDefaults.standard.set(newValue, forKey: Self.enabledKey) } } private var lastBackgroundedAt: Date? private var didColdLaunchLock = false private var isAuthenticating = false private init() { refreshAvailability() } // MARK: - 能力探测 /// 刷新「设备能否认证」与文案。进设置页 / 容器出现时调。 func refreshAvailability() { let ctx = LAContext() var error: NSError? // .deviceOwnerAuthentication:设备设了密码即为 true(含生物识别 + 密码兜底)。 biometryAvailable = ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) // biometryType 只有在 canEvaluatePolicy 调用后才有效。 switch ctx.biometryType { case .faceID: biometryLabel = "Face ID" case .touchID: biometryLabel = "Touch ID" default: biometryLabel = String(appLoc: "密码") } } // MARK: - 生命周期驱动(由 AppLockContainer 调) /// 冷启动:容器首次出现时调一次。 func handleAppear() { refreshAvailability() guard enabled, !didColdLaunchLock else { return } didColdLaunchLock = true isLocked = true Task { await authenticate() } } /// scenePhase 变化驱动。 func handleScenePhase(_ phase: ScenePhase) { switch phase { case .inactive: // 任务切换器 / 系统弹窗打断:盖遮罩(已锁定时锁屏本身就是遮罩)。 showsPrivacyCover = enabled && !isLocked case .background: lastBackgroundedAt = Date() showsPrivacyCover = enabled case .active: showsPrivacyCover = false if enabled, !isLocked, let since = lastBackgroundedAt, Date().timeIntervalSince(since) > Self.gracePeriod { isLocked = true } if isLocked { Task { await authenticate() } } lastBackgroundedAt = nil @unknown default: break } } // MARK: - 认证 /// 触发系统认证。成功 → 解锁;失败/取消 → 保持锁定。`isAuthenticating` 防重入, /// 避免容器与锁屏 onAppear 同时各弹一次。 func authenticate() async { guard isLocked, !isAuthenticating else { return } isAuthenticating = true defer { isAuthenticating = false } let ctx = LAContext() ctx.localizedFallbackTitle = String(appLoc: "输入密码") do { let ok = try await ctx.evaluatePolicy( .deviceOwnerAuthentication, localizedReason: String(appLoc: "解锁康康,查看你的健康档案") ) if ok { isLocked = false } } catch { // 失败/取消:停留锁屏,用户可点「解锁」重试。不抛给 UI。 } } // MARK: - 设置开关(MeView 调) /// 开启:先认证一次(验证设备可用 + 确认本人),成功才置 `enabled`。 /// 返回最终是否已开启。 @discardableResult func enableWithAuth() async -> Bool { let ctx = LAContext() ctx.localizedFallbackTitle = String(appLoc: "输入密码") do { let ok = try await ctx.evaluatePolicy( .deviceOwnerAuthentication, localizedReason: String(appLoc: "验证你本人,开启 Face ID 启动锁") ) if ok { enabled = true return true } } catch { // 取消/失败:不开启。 } return false } /// 关闭:直接关(此刻已在 App 内、本次已通过认证)。 func disable() { enabled = false } }