- Custom copper AccentColor and warm VaultBackground color assets (light/dark), applied to both the main app and the AutoFill extension (which needed its own copy of the asset catalog plus ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME and an explicit .tint() modifier, since extensions don't pick up the app-wide accent color the same way a full SwiftUI App/Scene does). - GroupFilterView no longer uses DisclosureGroup for subgroups -- the whole tree renders always-expanded (indented per depth) so active filters are visible at a glance instead of requiring tap-to-expand. - Added a placeholder app icon (padlock glyph, light/dark/tinted variants plus macOS sizes) -- previously the icon slot existed but had no actual images, which blocks App Store/TestFlight archive validation. - Added ITSAppUsesNonExemptEncryption = NO (standard encryption only, via KeePassKit) to skip the export-compliance prompt on each upload. - Renamed the app from MyPass to KeeVault throughout: Xcode project/targets/ schemes, the MyPassCore package (now KeeVaultCore) and every import site, folder and file names, bundle identifiers (org.antiloop222.keevault) and their App Group/Keychain-group entitlements, and remaining UI/string references -- MyPass was already taken as an App Store app name.
175 lines
5.9 KiB
Swift
175 lines
5.9 KiB
Swift
// AutoFill/Views/ExtensionUnlockView.swift
|
|
import SwiftUI
|
|
import Combine
|
|
import KeeVaultCore
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
.background(Color("VaultBackground"))
|
|
.navigationTitle("KeeVault")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel", action: vm.cancel)
|
|
}
|
|
}
|
|
}
|
|
.tint(Color("AccentColor"))
|
|
.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: "keevault://unlock")!) {
|
|
Label("Open KeeVault 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 KeeVault")
|
|
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)
|
|
}
|
|
}
|