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