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.
This commit is contained in:
2026-09-20 15:05:19 +02:00
parent a8d612f745
commit 8bba64076f
74 changed files with 363 additions and 176 deletions
@@ -0,0 +1,83 @@
// KeeVault/ViewModels/EntryEditViewModel.swift
import Foundation
import Combine
import SwiftUI
import KeeVaultCore
@MainActor
final class EntryEditViewModel: ObservableObject {
@Published var title: String
@Published var username: String
@Published var password: String
@Published var url: String
@Published var notes: String
@Published var customFields: [CustomField]
@Published var groupId: UUID
@Published var errorMessage: String?
@Published var isSaving: Bool = false
let session: VaultSession
private let existingEntry: Entry?
/// `groupId` is the target group for a new entry, or the entry's current group when
/// editing (shown read-only in that case -- KeeVault doesn't support re-parenting an
/// existing entry to a different group yet).
init(session: VaultSession, groupId: UUID, existing: Entry? = nil) {
self.session = session
self.groupId = groupId
self.existingEntry = existing
title = existing?.title ?? ""
username = existing?.username ?? ""
password = existing?.password.reveal() ?? ""
url = existing?.url ?? ""
notes = existing?.notes ?? ""
customFields = existing?.customFields ?? []
}
var isEditing: Bool { existingEntry != nil }
var flatGroups: [FlatGroup] {
guard let root = session.database?.root else { return [] }
return flattenGroups(in: root)
}
func save(dismiss: @escaping () -> Void) {
Task {
isSaving = true
errorMessage = nil
do {
if var entry = existingEntry {
entry.title = title
entry.username = username
entry.password = ProtectedString(password, isProtected: true)
entry.url = url
entry.notes = notes
entry.customFields = customFields
try session.updateEntry(entry)
} else {
let entry = Entry(
title: title,
username: username,
password: ProtectedString(password, isProtected: true),
url: url,
notes: notes,
customFields: customFields
)
try session.addEntry(entry, toGroupId: groupId)
}
dismiss()
} catch {
errorMessage = error.localizedDescription
}
isSaving = false
}
}
func addCustomField() {
customFields.append(CustomField(key: "", value: ProtectedString("", isProtected: false)))
}
func removeCustomField(at offsets: IndexSet) {
customFields.remove(atOffsets: offsets)
}
}
@@ -0,0 +1,33 @@
import Foundation
import Combine
import KeeVaultCore
@MainActor
final class GroupFilterViewModel: ObservableObject {
@Published var selectedGroupIds: Set<UUID> = []
/// The group id passed to the most recent `toggle(_:)` call that resulted in a non-empty
/// selection, used as a "best effort" default group for new entries. Cleared to `nil`
/// whenever a toggle empties the selection, or by `clear()`.
@Published private(set) var lastToggledGroupId: UUID?
private let session: VaultSession
init(session: VaultSession) {
self.session = session
}
func isSelected(_ group: KeeVaultCore.Group) -> Bool {
selectedGroupIds.contains(group.id)
}
func toggle(_ group: KeeVaultCore.Group) {
guard let root = session.database?.root else { return }
selectedGroupIds = GroupSelection.toggling(group.id, in: root, current: selectedGroupIds)
lastToggledGroupId = selectedGroupIds.isEmpty ? nil : group.id
}
func clear() {
selectedGroupIds = []
lastToggledGroupId = nil
}
}
+85
View File
@@ -0,0 +1,85 @@
//
// UnlockViewModel.swift
// KeeVault
//
import Foundation
import Combine
import KeeVaultCore
@MainActor
final class UnlockViewModel: ObservableObject {
@Published var password: String = ""
@Published var errorMessage: String?
@Published var isUnlocking: Bool = false
@Published var showFilePicker: Bool = false
private let session: VaultSession
private let bookmarkService: FileBookmarkService
private let keychainStore: KeychainStore
private let biometricService: BiometricAuthService
private let keychainAccount = "masterPassword"
init(
session: VaultSession,
bookmarkService: FileBookmarkService = .init(),
keychainStore: KeychainStore = .init(accessGroup: "F2KB6W8N4R.org.antiloop222.keevault"),
biometricService: BiometricAuthService = .init()
) {
self.session = session
self.bookmarkService = bookmarkService
self.keychainStore = keychainStore
self.biometricService = biometricService
}
var canUseBiometrics: Bool {
biometricService.isAvailable && keychainStore.exists(for: keychainAccount)
}
var hasVault: Bool { bookmarkService.hasBookmark }
func unlockWithBiometrics() {
Task {
isUnlocking = true
errorMessage = nil
do {
try await biometricService.authenticate(reason: "Unlock KeeVault")
let storedPassword = try keychainStore.load(for: keychainAccount)
let url = try bookmarkService.resolveURL()
defer { bookmarkService.stopAccess(url: url) }
try session.unlock(url: url, password: storedPassword)
} catch {
errorMessage = error.localizedDescription
}
isUnlocking = false
}
}
func unlockWithPassword() {
Task {
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: keychainAccount)
password = ""
} catch {
errorMessage = error.localizedDescription
}
isUnlocking = false
}
}
func openFile(url: URL) {
do {
try bookmarkService.save(url: url)
// Don't attempt to unlock yet -- picking a file only saves the bookmark.
// The view now shows the password field for the user to enter their password.
} catch {
errorMessage = error.localizedDescription
}
}
}
+52
View File
@@ -0,0 +1,52 @@
// KeeVault/ViewModels/VaultViewModel.swift
import Foundation
import Combine
import KeeVaultCore
@MainActor
final class VaultViewModel: ObservableObject {
@Published var searchQuery: String = ""
@Published var selectedGroupIds: Set<UUID> = []
@Published var lastToggledGroupId: UUID?
@Published var errorMessage: String?
let session: VaultSession
private var sessionCancellable: AnyCancellable?
init(session: VaultSession) {
self.session = session
sessionCancellable = session.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}
}
/// All entries in the vault, flattened with their group breadcrumb, before any filtering.
private var allFlatEntries: [FlatEntry] {
guard let root = session.database?.root else { return [] }
return flattenEntries(in: root)
}
/// Entries after applying the group filter, then the search filter (AND semantics).
var displayedEntries: [FlatEntry] {
var entries = allFlatEntries
if !selectedGroupIds.isEmpty {
entries = entries.filter { selectedGroupIds.contains($0.parentGroupId) }
}
if !searchQuery.isEmpty {
let q = searchQuery.lowercased()
entries = entries.filter {
$0.entry.title.lowercased().contains(q)
|| $0.entry.username.lowercased().contains(q)
|| $0.entry.url.lowercased().contains(q)
}
}
return entries
}
func deleteEntry(_ flatEntry: FlatEntry) {
Task {
do { try session.deleteEntry(id: flatEntry.entry.id, fromGroupId: flatEntry.parentGroupId) }
catch { errorMessage = error.localizedDescription }
}
}
}