#8 add app lock with passcode and biometric unlock
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

- 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:
wt committed 2026-08-29 05:59:55 +07:00
1 parent b0eb44d7a7
commit 0d78e02d2b
13 files changed
+663 -21

No files matched your search

+314
View File
@@ -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 = ""
}
}
}
}
}
+22 -16
View File
@@ -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)
+51
View File
@@ -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 "Подключено"