// 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 || vm.errorMessage != nil { 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 private var sessionCancellable: AnyCancellable? 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 sessionCancellable = session.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } } 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 { errorMessage = error.localizedDescription } 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) } }