- Lock only when the scene really backgrounds: transient inactive states (system photo picker, app switcher) no longer swap in the lock screen and break gallery selection on iOS. - Cache biometric availability once per session off the main thread instead of calling LAContext.canEvaluatePolicy in view bodies, which froze the macOS lock screen, and auto-prompt biometrics on iOS only. - Keep UI tests as an opt-in target and guard both fixes in verify.sh.
300 lines
9.9 KiB
Swift
300 lines
9.9 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 model.biometricUnlockAvailable, model.appLockBiometricIsEnabled {
|
|
Button {
|
|
Task { await biometricUnlock() }
|
|
} label: {
|
|
Label(
|
|
"Войти по \(model.biometricUnlockName)",
|
|
systemImage: model.biometricUnlockName == "Face ID" ? "faceid" : "touchid"
|
|
)
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
}
|
|
.padding(28)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
.padding(24)
|
|
}
|
|
.onAppear {
|
|
isFocused = true
|
|
}
|
|
#if os(iOS)
|
|
.task {
|
|
// Auto-prompt biometrics once when the lock screen appears. iOS
|
|
// only: on macOS the system Touch ID dialog blocks the window
|
|
// and freezes the lock screen.
|
|
guard model.biometricUnlockAvailable, model.appLockBiometricIsEnabled else {
|
|
return
|
|
}
|
|
await biometricUnlock()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
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 = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|