- 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.
315 lines
10 KiB
Swift
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 = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|