Files
keevault/AutoFill/Views/ExtensionUnlockView.swift
T
kriss 8bba64076f feat: copper accent theme, always-expanded group filter, app icon; rename MyPass to KeeVault
- 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.
2026-09-20 15:05:19 +02:00

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