Files
Luma/Sources/Shared/UI/AppLockView.swift
T
wt 0d78e02d2b
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
#8 add app lock with passcode and biometric unlock
- Gate every layer behind AppLockView when the lock is enabled and
  lock on launch and on backgrounding; the passcode lives only in the
  Keychain (AppLockVault) and the enable state defaults to off.
- Add the lock section to Settings: enable/setup, change passcode and
  disable all require the current passcode via a shared passcode
  sheet; Face ID / Touch ID unlock prompts automatically and can be
  toggled with passcode confirmation.
- Add NSFaceIDUsageDescription to both app targets, cover the passcode
  policy with tests, guard the feature in verify.sh and document it in
  SECURITY.md.
2026-08-29 05:59:55 +07:00

315 lines
10 KiB
Swift

import LocalAuthentication
import SwiftUI
@MainActor
struct AppLockView: View {
@ObservedObject var model: AppModel
@State private var passcode = ""
@State private var attemptFailed = false
@FocusState private var isFocused: Bool
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.38, blue: 0.78),
Color(red: 0.12, green: 0.62, blue: 0.96),
Color(red: 0.44, green: 0.84, blue: 0.96),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 22) {
Image(systemName: "lock.fill")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.white)
Text("Luma заблокирован")
.font(.title2.weight(.bold))
.foregroundStyle(.white)
SecureField("Пароль", text: $passcode)
.focused($isFocused)
.textContentType(.password)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
.onSubmit(submit)
if attemptFailed {
Text("Неверный пароль")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.95))
}
Button(action: submit) {
Text("Разблокировать")
.fontWeight(.semibold)
.frame(maxWidth: 260)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.disabled(passcode.isEmpty)
if biometricAvailable, model.appLockBiometricIsEnabled {
Button {
Task { await biometricUnlock() }
} label: {
Label("Войти по \(biometricName)", systemImage: biometricIcon)
.foregroundStyle(.white)
}
}
}
.padding(28)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
.padding(24)
}
.onAppear {
isFocused = true
}
.task {
// Auto-prompt biometrics once when the lock screen appears.
guard biometricAvailable, model.appLockBiometricIsEnabled else { return }
await biometricUnlock()
}
}
private var biometricContext: LAContext {
LAContext()
}
private var biometricAvailable: Bool {
var error: NSError?
return biometricContext.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
}
private var biometryType: LABiometryType {
biometricContext.biometryType
}
private var biometricIcon: String {
biometryType == .faceID ? "faceid" : "touchid"
}
private var biometricName: String {
biometryType == .faceID ? "Face ID" : "Touch ID"
}
private func submit() {
guard !passcode.isEmpty else { return }
if model.unlockWithPasscode(passcode) {
attemptFailed = false
passcode = ""
} else {
attemptFailed = true
passcode = ""
}
}
private func biometricUnlock() async {
_ = await model.unlockWithBiometrics()
}
}
/// Configurable passcode sheet: setting up a new passcode, changing it, or
/// verifying the current one to disable the lock / toggle biometrics.
@MainActor
struct AppLockPasscodeSheet: View {
enum Mode: Identifiable {
case setup
case change
case verify(VerificationAction)
enum VerificationAction: String {
case disableLock
case enableBiometrics
case disableBiometrics
}
var id: String {
switch self {
case .setup: return "setup"
case .change: return "change"
case .verify(let action): return "verify-\(action.rawValue)"
}
}
}
let model: AppModel
let mode: Mode
@Environment(\.dismiss) private var dismiss
@State private var oldPasscode = ""
@State private var passcode = ""
@State private var repeatedPasscode = ""
@State private var errorText: String?
var body: some View {
NavigationStack {
Form {
if needsOldPasscode {
Section("Текущий пароль") {
SecureField("Текущий пароль", text: $oldPasscode)
.textContentType(.password)
}
}
Section(title) {
SecureField(fieldLabel, text: $passcode)
.textContentType(.password)
if needsRepeatedPasscode {
SecureField("Повторите пароль", text: $repeatedPasscode)
.textContentType(.password)
}
}
if let errorText {
Section {
Text(errorText)
.font(.caption)
.foregroundStyle(.red)
}
}
}
.navigationTitle(navigationTitle)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Готово", action: submit)
.disabled(!canSubmit)
}
}
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 320)
#endif
}
private var needsOldPasscode: Bool {
switch mode {
case .change, .verify:
return true
case .setup:
return false
}
}
private var needsRepeatedPasscode: Bool {
switch mode {
case .setup, .change:
return true
case .verify:
return false
}
}
private var title: String {
switch mode {
case .setup: return "Новый пароль"
case .change: return "Новый пароль"
case .verify: return "Пароль"
}
}
private var navigationTitle: String {
switch mode {
case .setup: return "Включить блокировку"
case .change: return "Сменить пароль"
case .verify(let action):
switch action {
case .disableLock: return "Выключить блокировку"
case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль"
}
}
}
private var fieldLabel: String {
switch mode {
case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
case .verify: return "Пароль"
}
}
private var canSubmit: Bool {
switch mode {
case .setup:
return !passcode.isEmpty && !repeatedPasscode.isEmpty
case .change:
return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty
case .verify:
return !oldPasscode.isEmpty
}
}
private func submit() {
errorText = nil
switch mode {
case .setup:
guard AppLockPolicy.isValid(passcode) else {
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
return
}
guard passcode == repeatedPasscode else {
errorText = "Пароли не совпадают."
repeatedPasscode = ""
return
}
if model.enableAppLock(passcode: passcode) {
dismiss()
} else {
errorText = model.errorMessage ?? "Не удалось включить блокировку."
}
case .change:
guard AppLockPolicy.isValid(passcode) else {
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
return
}
guard passcode == repeatedPasscode else {
errorText = "Пароли не совпадают."
repeatedPasscode = ""
return
}
if model.changeAppLockPasscode(from: oldPasscode, to: passcode) {
dismiss()
} else {
errorText = "Неверный текущий пароль."
oldPasscode = ""
}
case .verify(let action):
switch action {
case .disableLock:
if model.disableAppLock(passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
case .enableBiometrics:
if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
case .disableBiometrics:
if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
}
}
}
}