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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user