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.
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.710",
|
||||
"green" : "0.396",
|
||||
"blue" : "0.114",
|
||||
"alpha" : "1.000"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.890",
|
||||
"green" : "0.635",
|
||||
"blue" : "0.357",
|
||||
"alpha" : "1.000"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024",
|
||||
"filename" : "icon-1024-light.png"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024",
|
||||
"filename" : "icon-1024-dark.png"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024",
|
||||
"filename" : "icon-1024-tinted.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16",
|
||||
"filename" : "icon-mac-16.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16",
|
||||
"filename" : "icon-mac-16@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32",
|
||||
"filename" : "icon-mac-32.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32",
|
||||
"filename" : "icon-mac-32@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128",
|
||||
"filename" : "icon-mac-128.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128",
|
||||
"filename" : "icon-mac-128@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256",
|
||||
"filename" : "icon-mac-256.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256",
|
||||
"filename" : "icon-mac-256@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512",
|
||||
"filename" : "icon-mac-512.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512",
|
||||
"filename" : "icon-mac-512@2x.png"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.980",
|
||||
"green" : "0.957",
|
||||
"blue" : "0.925",
|
||||
"alpha" : "1.000"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.141",
|
||||
"green" : "0.125",
|
||||
"blue" : "0.094",
|
||||
"alpha" : "1.000"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// KeeVault
|
||||
//
|
||||
// Created by Christophe Vila on 21/05/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import KeeVaultCore
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var session = VaultSession()
|
||||
|
||||
var body: some View {
|
||||
SwiftUI.Group {
|
||||
if session.isLocked {
|
||||
UnlockView(vm: UnlockViewModel(session: session))
|
||||
} else {
|
||||
VaultRootView(session: session)
|
||||
}
|
||||
}
|
||||
.background(Color("VaultBackground"))
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: sceneBackgroundNotification)
|
||||
) { _ in
|
||||
session.lock()
|
||||
}
|
||||
}
|
||||
|
||||
private var sceneBackgroundNotification: Notification.Name {
|
||||
#if os(iOS)
|
||||
UIScene.didEnterBackgroundNotification
|
||||
#else
|
||||
NSApplication.didResignActiveNotification
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the vault/group-filter view models for as long as the vault stays unlocked -- their
|
||||
/// state (search query, group filter selection) persists across re-renders but resets if the
|
||||
/// vault locks and is unlocked again (a fresh VaultRootView is created), which matches the
|
||||
/// expected "locking clears your filter" behavior.
|
||||
private struct VaultRootView: View {
|
||||
@StateObject private var vaultVM: VaultViewModel
|
||||
@StateObject private var filterVM: GroupFilterViewModel
|
||||
#if os(macOS)
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
#else
|
||||
@State private var showFilterSheet = false
|
||||
#endif
|
||||
|
||||
init(session: VaultSession) {
|
||||
_vaultVM = StateObject(wrappedValue: VaultViewModel(session: session))
|
||||
_filterVM = StateObject(wrappedValue: GroupFilterViewModel(session: session))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onChange(of: filterVM.selectedGroupIds) { _, newValue in
|
||||
vaultVM.selectedGroupIds = newValue
|
||||
}
|
||||
.onChange(of: filterVM.lastToggledGroupId) { _, newValue in
|
||||
vaultVM.lastToggledGroupId = newValue
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// macOS has room for a persistent sidebar; NavigationSplitView's columnVisibility
|
||||
// toggle works correctly at regular width.
|
||||
private var content: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
if let root = vaultVM.session.database?.root {
|
||||
GroupFilterView(vm: filterVM, rootGroup: root)
|
||||
}
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
EntryListView(vm: vaultVM)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
columnVisibility = columnVisibility == .all ? .detailOnly : .all
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
// NavigationSplitView's compact-width collapse only pushes to detail when the
|
||||
// sidebar drives navigation via row selection. Our sidebar is a multi-select
|
||||
// filter, not a picker, so on iPhone it would just show the sidebar forever with
|
||||
// no way to reach the entry list. Use a plain stack with the entry list as the
|
||||
// root, and present the group filter as a sheet instead.
|
||||
private var content: some View {
|
||||
NavigationStack {
|
||||
EntryListView(vm: vaultVM)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
showFilterSheet = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilterSheet) {
|
||||
NavigationStack {
|
||||
if let root = vaultVM.session.database?.root {
|
||||
GroupFilterView(vm: filterVM, rootGroup: root)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { showFilterSheet = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.org.antiloop222.keevault</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)org.antiloop222.keevault</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// KeeVaultApp.swift
|
||||
// KeeVault
|
||||
//
|
||||
// Created by Christophe Vila on 21/05/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct KeeVaultApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// BiometricAuthService.swift
|
||||
// KeeVault
|
||||
//
|
||||
|
||||
import LocalAuthentication
|
||||
import Foundation
|
||||
|
||||
public final class BiometricAuthService {
|
||||
public init() {}
|
||||
|
||||
public var isAvailable: Bool {
|
||||
let ctx = LAContext()
|
||||
var error: NSError?
|
||||
return ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
}
|
||||
|
||||
/// Returns true on success. On failure, throws an error with a localized message.
|
||||
public func authenticate(reason: String) async throws {
|
||||
let ctx = LAContext()
|
||||
try await ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// ClipboardService.swift
|
||||
// KeeVault
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum ClipboardService {
|
||||
public static func copy(_ text: String, expiresAfter seconds: TimeInterval = 30) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.setItems(
|
||||
[[UIPasteboard.typeAutomatic: text]],
|
||||
options: [.expirationDate: Date().addingTimeInterval(seconds)]
|
||||
)
|
||||
#else
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(text, forType: .string)
|
||||
let captured = text
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
|
||||
if pb.string(forType: .string) == captured { pb.clearContents() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// FileBookmarkService.swift
|
||||
// KeeVault
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Persists a security-scoped bookmark for the user's KDBX file in the shared App Group.
|
||||
public final class FileBookmarkService {
|
||||
private static let key = "kdbxBookmark"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(appGroup: String = "group.org.antiloop222.keevault") {
|
||||
defaults = UserDefaults(suiteName: appGroup) ?? .standard
|
||||
}
|
||||
|
||||
// `.withSecurityScope` is a macOS-only bookmark-creation option; iOS grants scoped
|
||||
// access automatically once the user picks a file, without that flag.
|
||||
private static var creationOptions: URL.BookmarkCreationOptions {
|
||||
#if os(macOS)
|
||||
return .withSecurityScope
|
||||
#else
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
|
||||
private static var resolutionOptions: URL.BookmarkResolutionOptions {
|
||||
#if os(macOS)
|
||||
return .withSecurityScope
|
||||
#else
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
|
||||
public func save(url: URL) throws {
|
||||
// For File Provider-backed URLs (e.g. iCloud Drive), creating a bookmark without
|
||||
// an active security scope produces one that resolves later with
|
||||
// NSCocoaErrorDomain Code=4 ("The file doesn't exist"), even immediately after picking.
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try url.bookmarkData(
|
||||
options: Self.creationOptions,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
|
||||
/// Returns the resolved URL, starting security access. Caller must call `stopAccess(url:)` when done.
|
||||
public func resolveURL() throws -> URL {
|
||||
guard let data = defaults.data(forKey: Self.key) else { throw BookmarkError.notFound }
|
||||
var isStale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: Self.resolutionOptions,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
if isStale {
|
||||
let fresh = try url.bookmarkData(options: Self.creationOptions, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||
defaults.set(fresh, forKey: Self.key)
|
||||
}
|
||||
guard url.startAccessingSecurityScopedResource() else { throw BookmarkError.accessDenied }
|
||||
return url
|
||||
}
|
||||
|
||||
public func stopAccess(url: URL) {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
defaults.removeObject(forKey: Self.key)
|
||||
}
|
||||
|
||||
public var hasBookmark: Bool {
|
||||
defaults.data(forKey: Self.key) != nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum BookmarkError: Error {
|
||||
case notFound
|
||||
case accessDenied
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// EntryDetailView.swift
|
||||
// KeeVault
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import KeeVaultCore
|
||||
|
||||
struct EntryDetailView: View {
|
||||
let entry: Entry
|
||||
let groupId: UUID
|
||||
let session: VaultSession
|
||||
|
||||
@State private var showPassword = false
|
||||
@State private var showEditSheet = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
credentialSection
|
||||
if !entry.url.isEmpty { urlSection }
|
||||
if !entry.notes.isEmpty { notesSection }
|
||||
if entry.totp != nil { totpSection }
|
||||
if !entry.customFields.isEmpty { customFieldsSection }
|
||||
if !entry.attachments.isEmpty { attachmentsSection }
|
||||
}
|
||||
.navigationTitle(entry.title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Edit") { showEditSheet = true }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditSheet) {
|
||||
EntryEditView(vm: EntryEditViewModel(session: session, groupId: groupId, existing: entry))
|
||||
}
|
||||
#if os(macOS)
|
||||
.keyboardShortcut("e", modifiers: .command)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var credentialSection: some View {
|
||||
Section("Credentials") {
|
||||
FieldRow(label: "Username", value: entry.username, isCopyable: true)
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Password").font(.caption).foregroundStyle(.secondary)
|
||||
Text(showPassword ? entry.password.reveal() : String(repeating: "•", count: 10))
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
Spacer()
|
||||
Button { showPassword.toggle() } label: {
|
||||
Image(systemName: showPassword ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button { ClipboardService.copy(entry.password.reveal()) } label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var urlSection: some View {
|
||||
Section("URL") {
|
||||
FieldRow(label: "URL", value: entry.url, isCopyable: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var notesSection: some View {
|
||||
Section("Notes") {
|
||||
Text(entry.notes)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
|
||||
private var totpSection: some View {
|
||||
Section("One-Time Password") {
|
||||
if let config = entry.totp {
|
||||
TOTPRow(config: config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customFieldsSection: some View {
|
||||
Section("Custom Fields") {
|
||||
ForEach(entry.customFields) { field in
|
||||
FieldRow(
|
||||
label: field.key,
|
||||
value: field.value.reveal(),
|
||||
isCopyable: true,
|
||||
isProtected: field.value.isProtected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var attachmentsSection: some View {
|
||||
Section("Attachments") {
|
||||
ForEach(entry.attachments) { att in
|
||||
Label(att.name, systemImage: "paperclip")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FieldRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var isCopyable: Bool = false
|
||||
var isProtected: Bool = false
|
||||
@State private var revealed = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
Text(isProtected && !revealed ? "••••••••" : value)
|
||||
.font(.body)
|
||||
}
|
||||
Spacer()
|
||||
if isProtected {
|
||||
Button { revealed.toggle() } label: {
|
||||
Image(systemName: revealed ? "eye.slash" : "eye")
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
if isCopyable {
|
||||
Button { ClipboardService.copy(value) } label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TOTPRow: View {
|
||||
let config: TOTPConfig
|
||||
@State private var code: String = ""
|
||||
@State private var secondsLeft: Int = 30
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("TOTP").font(.caption).foregroundStyle(.secondary)
|
||||
Text(formattedCode).font(.title3.monospaced()).bold()
|
||||
}
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 3)
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(secondsLeft) / CGFloat(config.period))
|
||||
.stroke(secondsLeft > 10 ? Color.green : Color.orange, lineWidth: 3)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 1), value: secondsLeft)
|
||||
Text("\(secondsLeft)").font(.caption2)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
Button { ClipboardService.copy(code) } label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
.onReceive(timer) { _ in refresh() }
|
||||
.onAppear { refresh() }
|
||||
}
|
||||
|
||||
private var formattedCode: String {
|
||||
guard code.count == 6 else { return code }
|
||||
return String(code.prefix(3)) + " " + String(code.suffix(3))
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
code = TOTPGenerator.generate(config: config)
|
||||
secondsLeft = TOTPGenerator.secondsRemaining(config: config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// KeeVault/Views/EntryEditView.swift
|
||||
import SwiftUI
|
||||
import KeeVaultCore
|
||||
|
||||
struct EntryEditView: View {
|
||||
@ObservedObject var vm: EntryEditViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if !vm.isEditing {
|
||||
Section("Group") {
|
||||
Picker("Group", selection: $vm.groupId) {
|
||||
ForEach(vm.flatGroups) { flatGroup in
|
||||
Text(groupLabel(for: flatGroup)).tag(flatGroup.group.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Credentials") {
|
||||
TextField("Title", text: $vm.title)
|
||||
TextField("Username", text: $vm.username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Password", text: $vm.password)
|
||||
.textContentType(.password)
|
||||
TextField("URL", text: $vm.url)
|
||||
.textContentType(.URL)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section("Notes") {
|
||||
TextEditor(text: $vm.notes)
|
||||
.frame(minHeight: 80)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach($vm.customFields) { $field in
|
||||
HStack {
|
||||
TextField("Key", text: $field.key)
|
||||
Divider()
|
||||
TextField("Value", text: Binding(
|
||||
get: { field.value.reveal() },
|
||||
set: { field.value = ProtectedString($0, isProtected: field.value.isProtected) }
|
||||
))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: vm.removeCustomField)
|
||||
Button("Add Field", action: vm.addCustomField)
|
||||
} header: {
|
||||
Text("Custom Fields")
|
||||
}
|
||||
|
||||
if let msg = vm.errorMessage {
|
||||
Section { Text(msg).foregroundStyle(.red) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(vm.isEditing ? "Edit Entry" : "New Entry")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { vm.save { dismiss() } }
|
||||
.disabled(vm.title.isEmpty || vm.isSaving)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLabel(for flatGroup: FlatGroup) -> String {
|
||||
(flatGroup.breadcrumb.map(\.name) + [flatGroup.group.name]).joined(separator: " > ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// KeeVault/Views/EntryListView.swift
|
||||
import SwiftUI
|
||||
import KeeVaultCore
|
||||
|
||||
struct EntryListView: View {
|
||||
@ObservedObject var vm: VaultViewModel
|
||||
@State private var showAddEntry = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(vm.displayedEntries) { flatEntry in
|
||||
NavigationLink(
|
||||
destination: EntryDetailView(
|
||||
entry: flatEntry.entry,
|
||||
groupId: flatEntry.parentGroupId,
|
||||
session: vm.session
|
||||
)
|
||||
) {
|
||||
EntryRow(flatEntry: flatEntry)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
offsets.map { vm.displayedEntries[$0] }.forEach(vm.deleteEntry)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color("VaultBackground"))
|
||||
.navigationTitle("KeeVault")
|
||||
.searchable(text: $vm.searchQuery, prompt: "Search entries…")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button { showAddEntry = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddEntry) {
|
||||
EntryEditView(vm: EntryEditViewModel(session: vm.session, groupId: defaultGroupId))
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { vm.errorMessage != nil },
|
||||
set: { if !$0 { vm.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { vm.errorMessage = nil }
|
||||
} message: {
|
||||
Text(vm.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recently toggled-on filter group, if any filter is still active via it,
|
||||
/// otherwise the vault root.
|
||||
private var defaultGroupId: UUID {
|
||||
vm.lastToggledGroupId ?? vm.session.database?.root.id ?? UUID()
|
||||
}
|
||||
}
|
||||
|
||||
private struct EntryRow: View {
|
||||
let flatEntry: FlatEntry
|
||||
|
||||
private var breadcrumbText: String? {
|
||||
guard !flatEntry.breadcrumb.isEmpty else { return nil }
|
||||
return flatEntry.breadcrumb.map(\.name).joined(separator: " > ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: KeePassIconMapper.symbolName(forIconIndex: flatEntry.entry.iconIndex))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(flatEntry.entry.title).font(.body)
|
||||
if let breadcrumbText {
|
||||
Text("\(flatEntry.entry.username) · \(breadcrumbText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text(flatEntry.entry.username)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
import KeeVaultCore
|
||||
|
||||
struct GroupFilterView: View {
|
||||
@ObservedObject var vm: GroupFilterViewModel
|
||||
let rootGroup: KeeVaultCore.Group
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !vm.selectedGroupIds.isEmpty {
|
||||
Button("Clear Filter", action: vm.clear)
|
||||
}
|
||||
GroupFilterNode(group: rootGroup, vm: vm)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color("VaultBackground"))
|
||||
.navigationTitle("Groups")
|
||||
}
|
||||
}
|
||||
|
||||
private struct GroupFilterNode: View {
|
||||
let group: KeeVaultCore.Group
|
||||
@ObservedObject var vm: GroupFilterViewModel
|
||||
var depth: Int = 0
|
||||
|
||||
var body: some View {
|
||||
// A plain SwiftUI.Group (not a container view) flattens its children into
|
||||
// separate List rows, so the whole subtree renders always-expanded --
|
||||
// no DisclosureGroup, no tap-to-reveal -- active filters are visible at a glance.
|
||||
SwiftUI.Group {
|
||||
row
|
||||
ForEach(group.subgroups) { sub in
|
||||
GroupFilterNode(group: sub, vm: vm, depth: depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var row: some View {
|
||||
Button {
|
||||
vm.toggle(group)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: vm.isSelected(group) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(vm.isSelected(group) ? Color.accentColor : Color.secondary)
|
||||
Label(group.name, systemImage: "folder")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, CGFloat(depth) * 16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// UnlockView.swift
|
||||
// KeeVault
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import KeeVaultCore
|
||||
|
||||
struct UnlockView: View {
|
||||
@ObservedObject var vm: UnlockViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.tint)
|
||||
Text("KeeVault")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
if vm.hasVault {
|
||||
vaultUnlockSection
|
||||
} else {
|
||||
openFileSection
|
||||
}
|
||||
|
||||
if let msg = vm.errorMessage {
|
||||
Text(msg)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(32)
|
||||
.fileImporter(
|
||||
isPresented: $vm.showFilePicker,
|
||||
allowedContentTypes: [.init(filenameExtension: "kdbx")!],
|
||||
onCompletion: { result in
|
||||
if let url = try? result.get() { vm.openFile(url: url) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var vaultUnlockSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
if vm.canUseBiometrics {
|
||||
Button(action: vm.unlockWithBiometrics) {
|
||||
Label("Use Face ID / Touch ID", systemImage: "faceid")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(vm.isUnlocking)
|
||||
|
||||
Text("or enter password")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
PasswordField(text: $vm.password, onSubmit: vm.unlockWithPassword)
|
||||
|
||||
Button("Unlock", action: vm.unlockWithPassword)
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(vm.password.isEmpty || vm.isUnlocking)
|
||||
|
||||
Button("Choose different file…") { vm.showFilePicker = true }
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var openFileSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("No vault selected. Open a .kdbx file to get started.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(action: { vm.showFilePicker = true }) {
|
||||
Label("Open KDBX File…", systemImage: "doc.badge.plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A password field with a trailing eye button to reveal/hide the typed value.
|
||||
private struct PasswordField: View {
|
||||
@Binding var text: String
|
||||
var onSubmit: () -> Void
|
||||
|
||||
@State private var isRevealed = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Group {
|
||||
if isRevealed {
|
||||
TextField("Master Password", text: $text)
|
||||
} else {
|
||||
SecureField("Master Password", text: $text)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.onSubmit(onSubmit)
|
||||
|
||||
Button {
|
||||
isRevealed.toggle()
|
||||
} label: {
|
||||
Image(systemName: isRevealed ? "eye.slash" : "eye")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||