feat: T-2.11 Face-ID gate + SettingsView
This commit is contained in:
parent
fb56c11a29
commit
e8b3cc422f
|
|
@ -7,6 +7,8 @@ final class AppState: ObservableObject {
|
|||
static let shared = AppState()
|
||||
|
||||
@Published var credential: SidecarCredential? = nil
|
||||
@Published var isLocked = false
|
||||
@Published var lastForegroundedAt: Date = Date()
|
||||
|
||||
private init() {
|
||||
// Try loading persisted credential on launch
|
||||
|
|
@ -22,4 +24,18 @@ final class AppState: ObservableObject {
|
|||
credential = nil
|
||||
Keychain.shared.delete(key: "piremote.credential")
|
||||
}
|
||||
|
||||
// MARK: - Face ID gate
|
||||
|
||||
func appDidBackground() {
|
||||
lastForegroundedAt = Date()
|
||||
}
|
||||
|
||||
func appWillForeground() async {
|
||||
let elapsed = Date().timeIntervalSince(lastForegroundedAt)
|
||||
guard elapsed > 60 else { return } // within 60s → no re-auth
|
||||
isLocked = true
|
||||
let ok = await FaceIDGate.authenticate()
|
||||
isLocked = !ok
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,31 @@ import SwiftUI
|
|||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let credential = appState.credential {
|
||||
MainTerminalView(credential: credential)
|
||||
} else {
|
||||
PairingFlowView { credential in
|
||||
appState.didPair(credential: credential)
|
||||
ZStack {
|
||||
Group {
|
||||
if let credential = appState.credential {
|
||||
MainTerminalView(credential: credential)
|
||||
} else {
|
||||
PairingFlowView { credential in
|
||||
appState.didPair(credential: credential)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, new in
|
||||
if new == .background {
|
||||
appState.appDidBackground()
|
||||
} else if new == .active {
|
||||
Task { await appState.appWillForeground() }
|
||||
}
|
||||
}
|
||||
|
||||
if appState.isLocked {
|
||||
LockView()
|
||||
}
|
||||
}
|
||||
.task { await appState.appWillForeground() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
// Sources/Core/Auth/FaceIDGate.swift
|
||||
// T-2.11: Biometric gate — evaluates Face ID if enabled in settings.
|
||||
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
/// Evaluates biometrics if Face ID is enabled in settings.
|
||||
/// Returns true if auth succeeded or Face ID is disabled.
|
||||
struct FaceIDGate: Sendable {
|
||||
@MainActor
|
||||
static func authenticate(reason: String = "Unlock pi remote") async -> Bool {
|
||||
guard UserDefaults.standard.bool(forKey: "faceid.enabled") else { return true }
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
return true // no biometrics available → allow
|
||||
}
|
||||
do {
|
||||
return try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Sources/UI/Settings/LockView.swift
|
||||
// T-2.11: Full-screen lock overlay shown when isLocked == true.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LockView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.ultraThinMaterial).ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "lock.fill").font(.system(size: 48))
|
||||
Text("Locked").font(.title2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// Sources/UI/Settings/SettingsView.swift
|
||||
// T-2.11: Settings sheet — Face ID toggle + credential info + unpair.
|
||||
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct SettingsView: View {
|
||||
let credential: SidecarCredential
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@AppStorage("faceid.enabled") private var faceIDEnabled = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Security") {
|
||||
Toggle("Require Face ID", isOn: $faceIDEnabled)
|
||||
if faceIDEnabled {
|
||||
Text("Face ID is required on launch and after 60 seconds in background.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sidecar") {
|
||||
LabeledContent("Name", value: credential.sidecarName)
|
||||
LabeledContent("Host", value: "\(credential.host):\(credential.port)")
|
||||
LabeledContent("Paired", value: credential.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
|
||||
Section("Danger") {
|
||||
Button("Unpair", role: .destructive) {
|
||||
appState.unpair()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ struct MainTerminalView: View {
|
|||
@State private var connection: SessionConnection? = nil
|
||||
@State private var statusText = "Connecting…"
|
||||
@State private var cancellables = Set<AnyCancellable>()
|
||||
@State private var showSettings = false
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -23,6 +24,10 @@ struct MainTerminalView: View {
|
|||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gear")
|
||||
}
|
||||
.font(.caption)
|
||||
Button("Unpair") {
|
||||
appState.unpair()
|
||||
}
|
||||
|
|
@ -51,6 +56,9 @@ struct MainTerminalView: View {
|
|||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.task { await bootstrap() }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(credential: credential).environmentObject(appState)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
|
|
|||
Loading…
Reference in New Issue