Gates an authenticated session behind Face ID / Touch ID with a 6-digit PIN fallback. AppLockManager (Keychain-backed enabled flag + SHA-256 PIN hash, all via KeychainHelper) arms the lock on scenePhase .background; RootView overlays LockScreenView above all auth states when locked (the lock screen also serves as the app-switcher privacy cover). AppLockSettingsView (Profile › App Lock) toggles it, sets/changes the PIN, and toggles biometrics. NSFaceIDUsageDescription added. Fully bypassed under UI tests (AppLockManager.isEnabled is false when UITestRuntime is enabled) so the XCUITest suite is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -758,6 +758,7 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "honeyDue uses Face ID to unlock the app.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -1246,6 +1247,7 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "honeyDue uses Face ID to unlock the app.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// App-lock: gates an already-authenticated session behind Face ID / Touch ID
|
||||||
|
/// with a numeric-PIN fallback. Sits ABOVE auth — RootView overlays the lock
|
||||||
|
/// screen when an authenticated user has it enabled and it's armed. All state
|
||||||
|
/// lives in the Keychain (via KeychainHelper). Fully disabled under UI tests so
|
||||||
|
/// the XCUITest suite is never gated by a lock screen.
|
||||||
|
@MainActor
|
||||||
|
final class AppLockManager: ObservableObject {
|
||||||
|
static let shared = AppLockManager()
|
||||||
|
|
||||||
|
static let pinLength = 6
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let enabled = "app_lock_enabled"
|
||||||
|
static let pinHash = "app_lock_pin_hash"
|
||||||
|
static let biometric = "app_lock_biometric_enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the lock screen should cover the app.
|
||||||
|
@Published private(set) var isLocked: Bool = false
|
||||||
|
|
||||||
|
private let keychain = KeychainHelper.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Locked on cold launch if the user enabled it.
|
||||||
|
isLocked = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether app-lock is on. Always false under UI tests.
|
||||||
|
var isEnabled: Bool {
|
||||||
|
if UITestRuntime.isEnabled { return false }
|
||||||
|
return keychain.get(key: Key.enabled) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isBiometricEnabled: Bool {
|
||||||
|
keychain.get(key: Key.biometric) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
var biometricAvailable: Bool {
|
||||||
|
LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var biometryType: LABiometryType {
|
||||||
|
let ctx = LAContext()
|
||||||
|
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||||
|
return ctx.biometryType
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enable / disable (settings)
|
||||||
|
|
||||||
|
func enable(pin: String, useBiometrics: Bool) {
|
||||||
|
_ = keychain.save(key: Key.pinHash, value: Self.hash(pin))
|
||||||
|
_ = keychain.save(key: Key.biometric, value: useBiometrics ? "true" : "false")
|
||||||
|
_ = keychain.save(key: Key.enabled, value: "true")
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func disable() {
|
||||||
|
_ = keychain.delete(key: Key.enabled)
|
||||||
|
_ = keychain.delete(key: Key.pinHash)
|
||||||
|
_ = keychain.delete(key: Key.biometric)
|
||||||
|
isLocked = false
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBiometric(_ on: Bool) {
|
||||||
|
_ = keychain.save(key: Key.biometric, value: on ? "true" : "false")
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock / unlock
|
||||||
|
|
||||||
|
/// Arm the lock when leaving the foreground so returning requires re-auth.
|
||||||
|
/// Called on scenePhase `.background`; the lock screen then also serves as
|
||||||
|
/// the app-switcher privacy cover.
|
||||||
|
func lockOnBackground() {
|
||||||
|
if isEnabled { isLocked = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock on a correct PIN (constant-time compare).
|
||||||
|
@discardableResult
|
||||||
|
func unlock(withPIN pin: String) -> Bool {
|
||||||
|
guard let stored = keychain.get(key: Key.pinHash) else { return false }
|
||||||
|
let ok = Self.constantTimeEquals(Self.hash(pin), stored)
|
||||||
|
if ok { isLocked = false }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock via Face ID / Touch ID.
|
||||||
|
func unlockWithBiometrics() async -> Bool {
|
||||||
|
let ctx = LAContext()
|
||||||
|
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return false }
|
||||||
|
do {
|
||||||
|
let ok = try await ctx.evaluatePolicy(
|
||||||
|
.deviceOwnerAuthenticationWithBiometrics,
|
||||||
|
localizedReason: String(localized: "Unlock honeyDue"))
|
||||||
|
if ok { isLocked = false }
|
||||||
|
return ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called on logout — never leave the login screen covered.
|
||||||
|
func clearLockState() {
|
||||||
|
isLocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PIN hashing
|
||||||
|
|
||||||
|
private static func hash(_ pin: String) -> String {
|
||||||
|
SHA256.hash(data: Data(pin.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func constantTimeEquals(_ a: String, _ b: String) -> Bool {
|
||||||
|
let ab = Array(a.utf8), bb = Array(b.utf8)
|
||||||
|
guard ab.count == bb.count else { return false }
|
||||||
|
var diff: UInt8 = 0
|
||||||
|
for i in 0..<ab.count { diff |= ab[i] ^ bb[i] }
|
||||||
|
return diff == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Settings screen to enable/disable App Lock, choose biometrics, and set/change
|
||||||
|
/// the PIN. Reached from the Profile/Settings screen.
|
||||||
|
struct AppLockSettingsView: View {
|
||||||
|
@ObservedObject private var lock = AppLockManager.shared
|
||||||
|
@State private var showPinSetup = false
|
||||||
|
@State private var pendingDisable = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { lock.isEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
if newValue {
|
||||||
|
showPinSetup = true // ask for a PIN before enabling
|
||||||
|
} else {
|
||||||
|
pendingDisable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Label("Require unlock", systemImage: "lock.fill")
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
}
|
||||||
|
.tint(Color.appPrimary)
|
||||||
|
} footer: {
|
||||||
|
Text("Lock honeyDue when you leave the app. You'll unlock with \(lock.biometricAvailable ? "Face ID / Touch ID or " : "")your PIN.")
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.appBackgroundSecondary)
|
||||||
|
|
||||||
|
if lock.isEnabled {
|
||||||
|
Section {
|
||||||
|
if lock.biometricAvailable {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { lock.isBiometricEnabled },
|
||||||
|
set: { lock.setBiometric($0) }
|
||||||
|
)) {
|
||||||
|
Label(lock.biometryType == .faceID ? "Use Face ID" : "Use Touch ID",
|
||||||
|
systemImage: lock.biometryType == .faceID ? "faceid" : "touchid")
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
}
|
||||||
|
.tint(Color.appPrimary)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showPinSetup = true
|
||||||
|
} label: {
|
||||||
|
Label("Change PIN", systemImage: "number")
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.appBackgroundSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.appBackgroundPrimary)
|
||||||
|
.navigationTitle("App Lock")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(isPresented: $showPinSetup) {
|
||||||
|
PinSetupView { pin in
|
||||||
|
lock.enable(pin: pin, useBiometrics: lock.biometricAvailable ? lock.isBiometricEnabled || !lock.isEnabled : false)
|
||||||
|
showPinSetup = false
|
||||||
|
} onCancel: {
|
||||||
|
showPinSetup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Turn off App Lock?", isPresented: $pendingDisable) {
|
||||||
|
Button("Turn Off", role: .destructive) { lock.disable() }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Your PIN will be removed and honeyDue will no longer lock.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-step PIN entry (enter, then confirm). Calls onDone with the PIN on match.
|
||||||
|
private struct PinSetupView: View {
|
||||||
|
var onDone: (String) -> Void
|
||||||
|
var onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var first = ""
|
||||||
|
@State private var confirm = ""
|
||||||
|
@State private var confirming = false
|
||||||
|
@State private var mismatch = false
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
private let pinLength = AppLockManager.pinLength
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Text(confirming ? "Re-enter your PIN" : "Choose a \(pinLength)-digit PIN")
|
||||||
|
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.padding(.top, 40)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(0..<pinLength, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.appPrimary, lineWidth: 1.5)
|
||||||
|
.background(Circle().fill(i < current.count ? Color.appPrimary : Color.clear))
|
||||||
|
.frame(width: 15, height: 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mismatch {
|
||||||
|
Text("PINs didn't match. Try again.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden field drives entry; tap the dots to focus.
|
||||||
|
TextField("", text: bindingForCurrent)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.focused($focused)
|
||||||
|
.opacity(0.02)
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.background(Color.appBackgroundPrimary.ignoresSafeArea())
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { focused = true }
|
||||||
|
.onAppear { focused = true }
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { onCancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var current: String { confirming ? confirm : first }
|
||||||
|
|
||||||
|
private var bindingForCurrent: Binding<String> {
|
||||||
|
confirming
|
||||||
|
? Binding(get: { confirm }, set: { handleConfirm($0) })
|
||||||
|
: Binding(get: { first }, set: { handleFirst($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFirst(_ v: String) {
|
||||||
|
first = String(v.prefix(pinLength).filter(\.isNumber))
|
||||||
|
if first.count == pinLength {
|
||||||
|
confirming = true
|
||||||
|
mismatch = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConfirm(_ v: String) {
|
||||||
|
confirm = String(v.prefix(pinLength).filter(\.isNumber))
|
||||||
|
if confirm.count == pinLength {
|
||||||
|
if confirm == first {
|
||||||
|
onDone(first)
|
||||||
|
} else {
|
||||||
|
mismatch = true
|
||||||
|
first = ""
|
||||||
|
confirm = ""
|
||||||
|
confirming = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
/// Full-screen lock overlay shown above the authenticated app when AppLock is
|
||||||
|
/// armed. Auto-prompts biometrics (if enabled) and offers a numeric PIN.
|
||||||
|
struct LockScreenView: View {
|
||||||
|
@ObservedObject private var lock = AppLockManager.shared
|
||||||
|
@State private var pin = ""
|
||||||
|
@State private var shake = false
|
||||||
|
@State private var biometricDismissed = false
|
||||||
|
|
||||||
|
private var showKeypad: Bool { biometricDismissed || !lock.isBiometricEnabled }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundPrimary.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
|
||||||
|
Text("honeyDue is locked")
|
||||||
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
// PIN progress dots
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(0..<AppLockManager.pinLength, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.appPrimary, lineWidth: 1.5)
|
||||||
|
.background(Circle().fill(i < pin.count ? Color.appPrimary : Color.clear))
|
||||||
|
.frame(width: 15, height: 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(x: shake ? -10 : 0)
|
||||||
|
.animation(.default, value: shake)
|
||||||
|
|
||||||
|
if showKeypad {
|
||||||
|
keypad
|
||||||
|
}
|
||||||
|
|
||||||
|
if lock.isBiometricEnabled {
|
||||||
|
Button {
|
||||||
|
Task { await tryBiometric() }
|
||||||
|
} label: {
|
||||||
|
Label(biometricLabel, systemImage: biometricIcon)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if lock.isBiometricEnabled { await tryBiometric() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keypad
|
||||||
|
|
||||||
|
private var keypad: some View {
|
||||||
|
let rows: [[String]] = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ["", "0", "<"]]
|
||||||
|
return VStack(spacing: 16) {
|
||||||
|
ForEach(rows.indices, id: \.self) { r in
|
||||||
|
HStack(spacing: 28) {
|
||||||
|
ForEach(rows[r], id: \.self) { key in
|
||||||
|
keyButton(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func keyButton(_ key: String) -> some View {
|
||||||
|
if key.isEmpty {
|
||||||
|
Color.clear.frame(width: 72, height: 72)
|
||||||
|
} else if key == "<" {
|
||||||
|
Button { if !pin.isEmpty { pin.removeLast() } } label: {
|
||||||
|
Image(systemName: "delete.left")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button { addDigit(key) } label: {
|
||||||
|
Text(key)
|
||||||
|
.font(.system(size: 28, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.background(Circle().fill(Color.appBackgroundSecondary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addDigit(_ d: String) {
|
||||||
|
guard pin.count < AppLockManager.pinLength else { return }
|
||||||
|
pin.append(d)
|
||||||
|
if pin.count == AppLockManager.pinLength {
|
||||||
|
if lock.unlock(withPIN: pin) {
|
||||||
|
pin = ""
|
||||||
|
} else {
|
||||||
|
shake.toggle()
|
||||||
|
let bad = pin
|
||||||
|
pin = ""
|
||||||
|
// brief haptic-ish reset
|
||||||
|
_ = bad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tryBiometric() async {
|
||||||
|
let ok = await lock.unlockWithBiometrics()
|
||||||
|
if !ok { biometricDismissed = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var biometricLabel: String {
|
||||||
|
lock.biometryType == .faceID ? String(localized: "Use Face ID") : String(localized: "Use Touch ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var biometricIcon: String {
|
||||||
|
lock.biometryType == .faceID ? "faceid" : "touchid"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,11 @@ struct ProfileTabView: View {
|
|||||||
NavigationLink(destination: Text(L10n.Profile.privacy)) {
|
NavigationLink(destination: Text(L10n.Profile.privacy)) {
|
||||||
Label(L10n.Profile.privacy, systemImage: "lock.shield")
|
Label(L10n.Profile.privacy, systemImage: "lock.shield")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink(destination: AppLockSettingsView()) {
|
||||||
|
Label("App Lock", systemImage: "lock.fill") // i18n-todo: add L10n.Profile.appLock key
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sectionBackground()
|
.sectionBackground()
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ class AuthenticationManager: ObservableObject {
|
|||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
|
|
||||||
|
// Never leave the login screen covered by the app-lock overlay.
|
||||||
|
AppLockManager.shared.clearLockState()
|
||||||
|
|
||||||
// Note: We don't reset onboarding state on logout
|
// Note: We don't reset onboarding state on logout
|
||||||
// so returning users go to login screen, not onboarding
|
// so returning users go to login screen, not onboarding
|
||||||
|
|
||||||
@@ -143,6 +146,7 @@ struct RootView: View {
|
|||||||
@EnvironmentObject private var themeManager: ThemeManager
|
@EnvironmentObject private var themeManager: ThemeManager
|
||||||
@StateObject private var authManager = AuthenticationManager.shared
|
@StateObject private var authManager = AuthenticationManager.shared
|
||||||
@StateObject private var onboardingState = OnboardingState.shared
|
@StateObject private var onboardingState = OnboardingState.shared
|
||||||
|
@StateObject private var appLock = AppLockManager.shared
|
||||||
@State private var refreshID = UUID()
|
@State private var refreshID = UUID()
|
||||||
@Binding var deepLinkResetToken: String?
|
@Binding var deepLinkResetToken: String?
|
||||||
|
|
||||||
@@ -202,7 +206,17 @@ struct RootView: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 1, height: 1)
|
.frame(width: 1, height: 1)
|
||||||
.accessibilityIdentifier("ui.app.ready")
|
.accessibilityIdentifier("ui.app.ready")
|
||||||
|
|
||||||
|
// App-lock overlay — covers everything for an authenticated user
|
||||||
|
// when the lock is armed. Sits above all auth states.
|
||||||
|
if appLock.isLocked && authManager.isAuthenticated && authManager.isVerified {
|
||||||
|
LockScreenView()
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(10)
|
||||||
|
.accessibilityIdentifier("ui.app.locked")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: appLock.isLocked)
|
||||||
.task {
|
.task {
|
||||||
// Trigger auth check here, after iOSApp.init() has completed
|
// Trigger auth check here, after iOSApp.init() has completed
|
||||||
// DataManager.initialize(). This avoids the race condition where
|
// DataManager.initialize(). This avoids the race condition where
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ struct iOSApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if newPhase == .background {
|
} else if newPhase == .background {
|
||||||
|
// Arm the app-lock so returning requires re-auth; the lock
|
||||||
|
// screen also serves as the app-switcher privacy cover.
|
||||||
|
AppLockManager.shared.lockOnBackground()
|
||||||
|
|
||||||
// Refresh widget when app goes to background
|
// Refresh widget when app goes to background
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user