From 543a0635286693d3ee68b30ea49ba4069ab47ead Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 19 Sep 2026 15:58:15 +0200 Subject: [PATCH] feat: add UnlockView with biometric and password unlock Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf --- MyPass/ViewModels/UnlockViewModel.swift | 86 ++++++++++++++++++++++++ MyPass/Views/UnlockView.swift | 88 +++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 MyPass/ViewModels/UnlockViewModel.swift create mode 100644 MyPass/Views/UnlockView.swift diff --git a/MyPass/ViewModels/UnlockViewModel.swift b/MyPass/ViewModels/UnlockViewModel.swift new file mode 100644 index 0000000..218e94c --- /dev/null +++ b/MyPass/ViewModels/UnlockViewModel.swift @@ -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 + } + } +} diff --git a/MyPass/Views/UnlockView.swift b/MyPass/Views/UnlockView.swift new file mode 100644 index 0000000..f252ead --- /dev/null +++ b/MyPass/Views/UnlockView.swift @@ -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) + } + } +}