feat: add UnlockView with biometric and password unlock

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
This commit is contained in:
2026-09-19 15:58:15 +02:00
co-authored by Claude Sonnet 5
parent ef52cb9804
commit 543a063528
2 changed files with 174 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
//
// UnlockViewModel.swift
// MyPass
//
import Foundation
import Combine
import MyPassCore
@MainActor
final class UnlockViewModel: ObservableObject {
@Published var password: String = ""
@Published var errorMessage: String?
@Published var isUnlocking: Bool = false
@Published var showFilePicker: Bool = false
private let session: VaultSession
private let bookmarkService: FileBookmarkService
private let keychainStore: KeychainStore
private let biometricService: BiometricAuthService
private let keychainAccount = "masterPassword"
init(
session: VaultSession,
bookmarkService: FileBookmarkService = .init(),
keychainStore: KeychainStore = .init(accessGroup: "org.antiloop222.mypass"),
biometricService: BiometricAuthService = .init()
) {
self.session = session
self.bookmarkService = bookmarkService
self.keychainStore = keychainStore
self.biometricService = biometricService
}
var canUseBiometrics: Bool {
biometricService.isAvailable && keychainStore.exists(for: keychainAccount)
}
var hasVault: Bool { bookmarkService.hasBookmark }
func unlockWithBiometrics() {
Task {
isUnlocking = true
errorMessage = nil
do {
try await biometricService.authenticate(reason: "Unlock MyPass")
let storedPassword = try keychainStore.load(for: keychainAccount)
let url = try bookmarkService.resolveURL()
defer { bookmarkService.stopAccess(url: url) }
try session.unlock(url: url, password: storedPassword)
} catch {
errorMessage = error.localizedDescription
}
isUnlocking = false
}
}
func unlockWithPassword() {
Task {
isUnlocking = true
errorMessage = nil
do {
let url = try bookmarkService.resolveURL()
defer { bookmarkService.stopAccess(url: url) }
try session.unlock(url: url, password: password)
try keychainStore.save(password: password, for: keychainAccount)
password = ""
} catch KDBXError.invalidPassword {
errorMessage = "Incorrect password."
} catch {
errorMessage = error.localizedDescription
}
isUnlocking = false
}
}
func openFile(url: URL) {
do {
try bookmarkService.save(url: url)
unlockWithPassword()
} catch {
errorMessage = error.localizedDescription
}
}
}
+88
View File
@@ -0,0 +1,88 @@
//
// UnlockView.swift
// MyPass
//
import SwiftUI
import MyPassCore
struct UnlockView: View {
@ObservedObject var vm: UnlockViewModel
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "lock.shield.fill")
.font(.system(size: 64))
.foregroundStyle(.tint)
Text("MyPass")
.font(.largeTitle.bold())
if vm.hasVault {
vaultUnlockSection
} else {
openFileSection
}
if let msg = vm.errorMessage {
Text(msg)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
}
Spacer()
}
.padding(32)
.fileImporter(
isPresented: $vm.showFilePicker,
allowedContentTypes: [.init(filenameExtension: "kdbx")!],
onCompletion: { result in
if let url = try? result.get() { vm.openFile(url: url) }
}
)
}
@ViewBuilder
private var vaultUnlockSection: some View {
VStack(spacing: 16) {
if vm.canUseBiometrics {
Button(action: vm.unlockWithBiometrics) {
Label("Use Face ID / Touch ID", systemImage: "faceid")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(vm.isUnlocking)
Text("or enter password")
.font(.caption)
.foregroundStyle(.secondary)
}
SecureField("Master Password", text: $vm.password)
.textFieldStyle(.roundedBorder)
.onSubmit(vm.unlockWithPassword)
Button("Unlock", action: vm.unlockWithPassword)
.buttonStyle(.bordered)
.disabled(vm.password.isEmpty || vm.isUnlocking)
Button("Choose different file…") { vm.showFilePicker = true }
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var openFileSection: some View {
VStack(spacing: 16) {
Text("No vault selected. Open a .kdbx file to get started.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button(action: { vm.showFilePicker = true }) {
Label("Open KDBX File…", systemImage: "doc.badge.plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
}