Files
keevault/AutoFill/Views/ExtensionUnlockView.swift
T
krissandClaude Sonnet 5 5281ce4895 fix: address AutoFill extension code review findings
- Remove dead provideCredentialWithoutUserInteraction override and
  unlockSilently() helper (nothing registers credential identities,
  so it could never run, and it was a footgun for future inline
  QuickType work).
- Surface non-cancellation biometric unlock failures into
  errorMessage instead of swallowing them, matching
  UnlockViewModel.unlockWithBiometrics().
- Forward VaultSession.objectWillChange into ExtensionViewModel via
  Combine so the lock/unlock UI transition no longer depends on an
  accidental isUnlocking side effect, matching VaultViewModel's
  pattern.
- Show the "Open MyPass" deep-link escape hatch whenever unlock
  fails (errorMessage set), not only when there's no bookmark yet,
  per the extension constraints spec.
- Add INFOPLIST_KEY_NSFaceIDUsageDescription to the MyPass and
  AutoFill targets' Debug/Release build configs.

Also folds in pre-existing alphabetical reordering of two
PBXBuildFile/PBXFileReference entries in project.pbxproj from an
earlier task, since this same file is already being touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
2026-09-19 19:24:35 +02:00

173 lines
5.8 KiB
Swift

// 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)
}
}