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