Compare commits
2 Commits
main
...
feat/p2-t2
| Author | SHA1 | Date |
|---|---|---|
|
|
d91cebef6c | |
|
|
e8b3cc422f |
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
NavigationStack {
|
||||||
|
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 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue