diff --git a/AutoFill/Views/ExtensionUnlockView.swift b/AutoFill/Views/ExtensionUnlockView.swift new file mode 100644 index 0000000..6ecc1a3 --- /dev/null +++ b/AutoFill/Views/ExtensionUnlockView.swift @@ -0,0 +1,168 @@ +// AutoFill/Views/ExtensionUnlockView.swift +import SwiftUI +import Combine +import MyPassCore + +/// Root view for the extension -- shows unlock screen or credential list depending on state. +struct ExtensionRootView: View { + @StateObject private var vm: ExtensionViewModel + + init( + session: VaultSession, + serviceIdentifiers: [String], + bookmarkService: FileBookmarkService, + keychainStore: KeychainStore, + biometricService: BiometricAuthService, + onSelect: @escaping (Entry) -> Void, + onCancel: @escaping () -> Void + ) { + _vm = StateObject(wrappedValue: ExtensionViewModel( + session: session, + serviceIdentifiers: serviceIdentifiers, + bookmarkService: bookmarkService, + keychainStore: keychainStore, + biometricService: biometricService, + onSelect: onSelect, + onCancel: onCancel + )) + } + + var body: some View { + NavigationStack { + SwiftUI.Group { + if vm.isLocked { + extensionUnlockView + } else { + CredentialListView(vm: vm) + } + } + .navigationTitle("MyPass") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: vm.cancel) + } + } + } + .task { await vm.tryBiometricUnlock() } + } + + private var extensionUnlockView: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "lock.shield.fill").font(.system(size: 48)).foregroundStyle(.tint) + Text("Vault Locked").font(.headline) + + if vm.canUseBiometrics { + Button(action: { Task { await vm.tryBiometricUnlock() } }) { + Label("Use Face ID / Touch ID", systemImage: "faceid") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + + SecureField("Master Password", text: $vm.password) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await vm.unlockWithPassword() } } + + Button("Unlock") { Task { await vm.unlockWithPassword() } } + .buttonStyle(.bordered) + .disabled(vm.password.isEmpty || vm.isUnlocking) + + if let msg = vm.errorMessage { + Text(msg).foregroundStyle(.red).font(.caption).multilineTextAlignment(.center) + } + + if !vm.hasVault { + Link(destination: URL(string: "mypass://unlock")!) { + Label("Open MyPass to set up vault", systemImage: "arrow.up.right") + .font(.caption) + } + } + Spacer() + } + .padding(24) + } +} + +@MainActor +final class ExtensionViewModel: ObservableObject { + @Published var password: String = "" + @Published var errorMessage: String? + @Published var isUnlocking: Bool = false + + let session: VaultSession + let serviceIdentifiers: [String] + private let bookmarkService: FileBookmarkService + private let keychainStore: KeychainStore + private let biometricService: BiometricAuthService + private let onSelect: (Entry) -> Void + private let onCancel: () -> Void + + init( + session: VaultSession, + serviceIdentifiers: [String], + bookmarkService: FileBookmarkService, + keychainStore: KeychainStore, + biometricService: BiometricAuthService, + onSelect: @escaping (Entry) -> Void, + onCancel: @escaping () -> Void + ) { + self.session = session + self.serviceIdentifiers = serviceIdentifiers + self.bookmarkService = bookmarkService + self.keychainStore = keychainStore + self.biometricService = biometricService + self.onSelect = onSelect + self.onCancel = onCancel + } + + var isLocked: Bool { session.isLocked } + var hasVault: Bool { bookmarkService.hasBookmark } + var canUseBiometrics: Bool { biometricService.isAvailable && keychainStore.exists(for: "masterPassword") } + + var suggestedEntries: [Entry] { + CredentialMatcher.filter(entries: session.allEntries(), for: serviceIdentifiers).suggested + } + + var allEntries: [Entry] { + CredentialMatcher.filter(entries: session.allEntries(), for: serviceIdentifiers).all + } + + func select(_ entry: Entry) { onSelect(entry) } + func cancel() { onCancel() } + + func tryBiometricUnlock() async { + guard canUseBiometrics, session.isLocked else { return } + isUnlocking = true + do { + try await biometricService.authenticate(reason: "Unlock MyPass") + try performUnlockFromKeychain() + } catch { + // Silently fail -- user can type password + } + isUnlocking = false + } + + func unlockWithPassword() async { + 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: "masterPassword") + password = "" + } catch { + errorMessage = error.localizedDescription + } + isUnlocking = false + } + + private func performUnlockFromKeychain() throws { + let pw = try keychainStore.load(for: "masterPassword") + let url = try bookmarkService.resolveURL() + defer { bookmarkService.stopAccess(url: url) } + try session.unlock(url: url, password: pw) + } +}