From e8b3cc422fa1dfdd0df05396d08ae541e2a33a2d Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 16 May 2026 04:16:43 +0200 Subject: [PATCH] feat: T-2.11 Face-ID gate + SettingsView --- Sources/App/AppState.swift | 16 ++++++++ Sources/App/ContentView.swift | 27 +++++++++--- Sources/Core/Auth/FaceIDGate.swift | 24 +++++++++++ Sources/UI/Settings/LockView.swift | 16 ++++++++ Sources/UI/Settings/SettingsView.swift | 48 ++++++++++++++++++++++ Sources/UI/Terminal/MainTerminalView.swift | 8 ++++ 6 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 Sources/Core/Auth/FaceIDGate.swift create mode 100644 Sources/UI/Settings/LockView.swift create mode 100644 Sources/UI/Settings/SettingsView.swift diff --git a/Sources/App/AppState.swift b/Sources/App/AppState.swift index 9caf945..c27eacb 100644 --- a/Sources/App/AppState.swift +++ b/Sources/App/AppState.swift @@ -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 + } } diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index de57dfa..86c4cd5 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -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() } } } diff --git a/Sources/Core/Auth/FaceIDGate.swift b/Sources/Core/Auth/FaceIDGate.swift new file mode 100644 index 0000000..e96424e --- /dev/null +++ b/Sources/Core/Auth/FaceIDGate.swift @@ -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 + } + } +} diff --git a/Sources/UI/Settings/LockView.swift b/Sources/UI/Settings/LockView.swift new file mode 100644 index 0000000..80da0af --- /dev/null +++ b/Sources/UI/Settings/LockView.swift @@ -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) + } + } + } +} diff --git a/Sources/UI/Settings/SettingsView.swift b/Sources/UI/Settings/SettingsView.swift new file mode 100644 index 0000000..3c9d356 --- /dev/null +++ b/Sources/UI/Settings/SettingsView.swift @@ -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() } + } + } + } + } +} diff --git a/Sources/UI/Terminal/MainTerminalView.swift b/Sources/UI/Terminal/MainTerminalView.swift index 235418c..f3d8d66 100644 --- a/Sources/UI/Terminal/MainTerminalView.swift +++ b/Sources/UI/Terminal/MainTerminalView.swift @@ -13,6 +13,7 @@ struct MainTerminalView: View { @State private var connection: SessionConnection? = nil @State private var statusText = "Connecting…" @State private var cancellables = Set() + @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