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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user