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,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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+6
View File
@@ -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
}
}
+126
View File
@@ -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
}
+16
View File
@@ -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>
+17
View File
@@ -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)
}
}
+30
View File
@@ -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
}
}
+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 }
}
}
}
+176
View File
@@ -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)
}
}
+82
View File
@@ -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: " > ")
}
}
+86
View File
@@ -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)
}
}
}
}
}
+51
View File
@@ -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)
}
}
+126
View File
@@ -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)
)
}
}