feat: T-2.11 Face-ID gate + SettingsView

This commit is contained in:
jay 2026-05-16 04:16:43 +02:00
parent fb56c11a29
commit e8b3cc422f
6 changed files with 133 additions and 6 deletions

View File

@ -7,6 +7,8 @@ final class AppState: ObservableObject {
static let shared = AppState() static let shared = AppState()
@Published var credential: SidecarCredential? = nil @Published var credential: SidecarCredential? = nil
@Published var isLocked = false
@Published var lastForegroundedAt: Date = Date()
private init() { private init() {
// Try loading persisted credential on launch // Try loading persisted credential on launch
@ -22,4 +24,18 @@ final class AppState: ObservableObject {
credential = nil credential = nil
Keychain.shared.delete(key: "piremote.credential") 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
}
} }

View File

@ -4,8 +4,10 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@Environment(\.scenePhase) private var scenePhase
var body: some View { var body: some View {
ZStack {
Group { Group {
if let credential = appState.credential { if let credential = appState.credential {
MainTerminalView(credential: credential) MainTerminalView(credential: credential)
@ -15,5 +17,18 @@ struct ContentView: View {
} }
} }
} }
.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() }
} }
} }

View File

@ -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
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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() }
}
}
}
}
}

View File

@ -13,6 +13,7 @@ struct MainTerminalView: View {
@State private var connection: SessionConnection? = nil @State private var connection: SessionConnection? = nil
@State private var statusText = "Connecting…" @State private var statusText = "Connecting…"
@State private var cancellables = Set<AnyCancellable>() @State private var cancellables = Set<AnyCancellable>()
@State private var showSettings = false
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
var body: some View { var body: some View {
@ -23,6 +24,10 @@ struct MainTerminalView: View {
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
Button { showSettings = true } label: {
Image(systemName: "gear")
}
.font(.caption)
Button("Unpair") { Button("Unpair") {
appState.unpair() appState.unpair()
} }
@ -51,6 +56,9 @@ struct MainTerminalView: View {
.background(Color(uiColor: .secondarySystemBackground)) .background(Color(uiColor: .secondarySystemBackground))
} }
.task { await bootstrap() } .task { await bootstrap() }
.sheet(isPresented: $showSettings) {
SettingsView(credential: credential).environmentObject(appState)
}
} }
// MARK: - Bootstrap // MARK: - Bootstrap