#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.
This commit is contained in:
1 parent
b0eb44d7a7
commit
0d78e02d2b
13 files changed
+663
-21
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure policy for the app-lock passcode: minimum length and validation.
|
||||
/// Kept separate from the Keychain storage so it is unit-testable.
|
||||
enum AppLockPolicy {
|
||||
static let minimumLength = 4
|
||||
|
||||
static func isValid(_ passcode: String) -> Bool {
|
||||
passcode.count >= minimumLength
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import SwiftData
|
||||
import UniformTypeIdentifiers
|
||||
import WebRTC
|
||||
@@ -56,6 +57,9 @@ final class AppModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var informationalMessage: String?
|
||||
@Published var previewURL: URL?
|
||||
@Published private(set) var isAppLocked = false
|
||||
@Published private(set) var appLockIsEnabled = false
|
||||
@Published private(set) var appLockBiometricIsEnabled = false
|
||||
@Published private(set) var mediaViewerItem: MediaViewerItem?
|
||||
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
|
||||
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
|
||||
@@ -68,6 +72,7 @@ final class AppModel: ObservableObject {
|
||||
private let xmpp: XMPPService
|
||||
private let credentials: CredentialVault
|
||||
private let preferences: AccountPreferences
|
||||
private let appLock = AppLockVault()
|
||||
private let notifications: NotificationCoordinator
|
||||
private let watchBridge: PhoneWatchBridge
|
||||
private let avatarCache: AvatarCache
|
||||
@@ -129,6 +134,13 @@ final class AppModel: ObservableObject {
|
||||
self.watchBridge = watchBridge
|
||||
self.avatarCache = avatarCache
|
||||
|
||||
// The app lock must be initialised before any view reads it; the
|
||||
// locked state persists only in memory and is re-applied from the
|
||||
// stored preferences on launch.
|
||||
appLockIsEnabled = appLock.isEnabled
|
||||
appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
|
||||
isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests
|
||||
|
||||
xmpp.eventHandler = { [weak self] event in
|
||||
self?.consume(event)
|
||||
}
|
||||
@@ -454,12 +466,89 @@ final class AppModel: ObservableObject {
|
||||
if !active {
|
||||
resetTypingState()
|
||||
syncWatch(immediate: true)
|
||||
if appLockIsEnabled {
|
||||
isAppLocked = true
|
||||
}
|
||||
}
|
||||
if active, case .disconnected(_) = connectionStatus {
|
||||
Task { await reconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App lock
|
||||
|
||||
func unlockWithPasscode(_ passcode: String) -> Bool {
|
||||
guard appLock.verify(passcode: passcode) else { return false }
|
||||
isAppLocked = false
|
||||
return true
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
guard appLockIsEnabled, appLockBiometricIsEnabled else { return false }
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let granted = try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: "Разблокируйте Luma"
|
||||
)
|
||||
guard granted else { return false }
|
||||
isAppLocked = false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func enableAppLock(passcode: String) -> Bool {
|
||||
guard AppLockPolicy.isValid(passcode) else { return false }
|
||||
do {
|
||||
try appLock.save(passcode: passcode)
|
||||
appLock.isEnabled = true
|
||||
appLockIsEnabled = true
|
||||
isAppLocked = false
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disableAppLock(passcode: String) -> Bool {
|
||||
guard appLock.verify(passcode: passcode) else { return false }
|
||||
try? appLock.deletePasscode()
|
||||
appLock.isEnabled = false
|
||||
appLock.biometricUnlockEnabled = false
|
||||
appLockIsEnabled = false
|
||||
appLockBiometricIsEnabled = false
|
||||
isAppLocked = false
|
||||
return true
|
||||
}
|
||||
|
||||
func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool {
|
||||
guard appLock.verify(passcode: passcode) else { return false }
|
||||
appLock.biometricUnlockEnabled = enabled
|
||||
appLockBiometricIsEnabled = enabled
|
||||
return true
|
||||
}
|
||||
|
||||
func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool {
|
||||
guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try appLock.save(passcode: newPasscode)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setVideoNoteCaptureActive(_ active: Bool) {
|
||||
videoNoteCaptureIsActive = active
|
||||
updateArchiveSyncSuspensionForMedia()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Stores the app-lock passcode in the Keychain and the lock preferences in
|
||||
/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in
|
||||
/// the Keychain and is never mirrored into memory beyond verification.
|
||||
final class AppLockVault {
|
||||
private let service = "app.luma.chat.applock"
|
||||
private let account = "applock-passcode"
|
||||
private let enabledKey = "appLockEnabled"
|
||||
private let biometricKey = "appLockBiometricUnlock"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
get { defaults.bool(forKey: enabledKey) }
|
||||
set { defaults.set(newValue, forKey: enabledKey) }
|
||||
}
|
||||
|
||||
var biometricUnlockEnabled: Bool {
|
||||
get { defaults.bool(forKey: biometricKey) }
|
||||
set { defaults.set(newValue, forKey: biometricKey) }
|
||||
}
|
||||
|
||||
func save(passcode: String) throws {
|
||||
let data = Data(passcode.utf8)
|
||||
let baseQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(
|
||||
baseQuery as CFDictionary,
|
||||
[kSecValueData: data] as CFDictionary
|
||||
)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
guard updateStatus == errSecItemNotFound else {
|
||||
throw AppLockVaultError.unhandled(updateStatus)
|
||||
}
|
||||
var insertion = baseQuery
|
||||
insertion[kSecValueData] = data
|
||||
#if !os(macOS)
|
||||
insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
#endif
|
||||
let addStatus = SecItemAdd(insertion as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw AppLockVaultError.unhandled(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func deletePasscode() throws {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AppLockVaultError.unhandled(status)
|
||||
}
|
||||
}
|
||||
|
||||
func verify(passcode: String) -> Bool {
|
||||
guard let stored = storedPasscode() else { return false }
|
||||
return passcode == stored
|
||||
}
|
||||
|
||||
private func storedPasscode() -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess,
|
||||
let data = item as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
enum AppLockVaultError: LocalizedError {
|
||||
case unhandled(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unhandled(let status):
|
||||
let message = SecCopyErrorMessageString(status, nil) as String?
|
||||
return message ?? "Не удалось обратиться к Keychain (\(status))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,31 @@ struct RootView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if model.account == nil {
|
||||
LoginView(model: model)
|
||||
} else {
|
||||
MainChatView(model: model)
|
||||
.environment(\.modelContext, model.modelContext)
|
||||
if model.isAppLocked {
|
||||
// The lock covers every layer: chat list, media viewer and
|
||||
// calls stay hidden until the passcode or biometrics succeed.
|
||||
AppLockView(model: model)
|
||||
} else {
|
||||
Group {
|
||||
if model.account == nil {
|
||||
LoginView(model: model)
|
||||
} else {
|
||||
MainChatView(model: model)
|
||||
.environment(\.modelContext, model.modelContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let item = model.mediaViewerItem {
|
||||
MediaViewer(item: item, onClose: model.closeMediaViewer)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||
.zIndex(100)
|
||||
}
|
||||
if let item = model.mediaViewerItem {
|
||||
MediaViewer(item: item, onClose: model.closeMediaViewer)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||
.zIndex(100)
|
||||
}
|
||||
|
||||
if let call = model.activeCall {
|
||||
CallView(model: model, call: call)
|
||||
.transition(.opacity.combined(with: .scale(scale: 1.015)))
|
||||
.zIndex(200)
|
||||
if let call = model.activeCall {
|
||||
CallView(model: model, call: call)
|
||||
.transition(.opacity.combined(with: .scale(scale: 1.015)))
|
||||
.zIndex(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LocalAuthentication
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
@@ -7,6 +8,7 @@ struct SettingsView: View {
|
||||
@State private var showingSignOutConfirmation = false
|
||||
@State private var forgetHistory = false
|
||||
@State private var avatarItem: PhotosPickerItem?
|
||||
@State private var passcodeSheetMode: AppLockPasscodeSheet.Mode?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -98,6 +100,36 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Блокировка приложения") {
|
||||
Toggle("Заблокировать приложение", isOn: Binding(
|
||||
get: { model.appLockIsEnabled },
|
||||
set: { enabled in
|
||||
if enabled {
|
||||
passcodeSheetMode = .setup
|
||||
} else {
|
||||
passcodeSheetMode = .verify(.disableLock)
|
||||
}
|
||||
}
|
||||
))
|
||||
if model.appLockIsEnabled {
|
||||
Toggle("Вход по \(biometricName)", isOn: Binding(
|
||||
get: { model.appLockBiometricIsEnabled },
|
||||
set: { enabled in
|
||||
passcodeSheetMode = .verify(
|
||||
enabled ? .enableBiometrics : .disableBiometrics
|
||||
)
|
||||
}
|
||||
))
|
||||
.disabled(!biometricAvailable)
|
||||
Button("Сменить пароль") {
|
||||
passcodeSheetMode = .change
|
||||
}
|
||||
}
|
||||
Text("При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Приватность чата") {
|
||||
Toggle("Показывать статус набора", isOn: Binding(
|
||||
get: { model.typingIndicatorsEnabled },
|
||||
@@ -153,12 +185,31 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $passcodeSheetMode) { mode in
|
||||
AppLockPasscodeSheet(model: model, mode: mode)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 500, minHeight: 560)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var biometricContext: LAContext {
|
||||
LAContext()
|
||||
}
|
||||
|
||||
private var biometricAvailable: Bool {
|
||||
var error: NSError?
|
||||
return biometricContext.canEvaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
error: &error
|
||||
)
|
||||
}
|
||||
|
||||
private var biometricName: String {
|
||||
biometricContext.biometryType == .faceID ? "Face ID" : "Touch ID"
|
||||
}
|
||||
|
||||
private var connectionTitle: String {
|
||||
switch model.connectionStatus {
|
||||
case .connected: return "Подключено"
|
||||
|
||||
Reference in new issue
Block a user