feat: add ExtensionRootView and ExtensionViewModel
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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user