Skip to content

Commit bdd0994

Browse files
committed
Add main-app entry editing UI
1 parent 4e082f4 commit bdd0994

27 files changed

Lines changed: 2609 additions & 147 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
## Unreleased
1111

12+
### New Features
13+
- Add main-app entry editing with create, edit, delete, password generation, save-conflict resolution, and read-only editing safeguards
14+
1215
### Fixes
1316
- Remove the baked white background from the Dropbox provider glyph so it renders cleanly in dark mode
1417
- Show provider-specific cloud sync status during unlock, and add focused unlock coverage for cloud sync success, fallback, and failure paths

KeeForge.xcodeproj/project.pbxproj

Lines changed: 34 additions & 0 deletions
Large diffs are not rendered by default.

KeeForge/App/KeeForgeApp.swift

Lines changed: 200 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import AuthenticationServices
12
import SwiftUI
3+
import UIKit
24

35
@main
46
struct KeeForgeApp: App {
@@ -24,7 +26,7 @@ struct KeeForgeApp: App {
2426
break
2527
case .background:
2628
screenProtectionService.showShield()
27-
activeDatabaseViewModel?.lock()
29+
activeDatabaseViewModel?.lockRequest()
2830
@unknown default:
2931
screenProtectionService.showShield()
3032
}
@@ -236,12 +238,14 @@ private struct LaunchRoutingView: View {
236238

237239
struct DatabaseNavigationView: View {
238240
@Bindable var viewModel: DatabaseViewModel
241+
@State private var presentedSaveError: DatabaseSaveError?
242+
@State private var isDropboxReconnectInFlight = false
239243

240244
var body: some View {
241245
NavigationStack(path: $viewModel.navigationPath) {
242246
Group {
243-
if let root = viewModel.rootGroup {
244-
GroupListView(group: root, viewModel: viewModel)
247+
if let root = viewModel.currentRootGroup {
248+
GroupListView(groupID: root.id, viewModel: viewModel)
245249
} else {
246250
ContentUnavailableView(
247251
"Vault Not Loaded",
@@ -251,22 +255,205 @@ struct DatabaseNavigationView: View {
251255
}
252256
}
253257
.navigationDestination(for: KPGroup.self) { group in
254-
GroupListView(group: group, viewModel: viewModel)
258+
GroupListView(groupID: group.id, viewModel: viewModel)
255259
}
256260
.navigationDestination(for: KPEntry.self) { entry in
257-
EntryDetailView(entry: entry, sessionKey: viewModel.sessionKey!)
261+
EntryDetailView(entryID: entry.id, viewModel: viewModel)
262+
}
263+
.toolbar {
264+
ToolbarItem(placement: .topBarTrailing) {
265+
DatabaseSaveToolbarButton(viewModel: viewModel)
266+
}
258267
}
259268
.safeAreaInset(edge: .top, spacing: 0) {
260-
if let bannerText = viewModel.cloudSyncBannerText {
261-
Label(bannerText, systemImage: "icloud")
262-
.font(.caption.weight(.medium))
263-
.foregroundStyle(.orange)
264-
.frame(maxWidth: .infinity, alignment: .leading)
265-
.padding(.horizontal, 16)
266-
.padding(.vertical, 10)
267-
.background(Color.orange.opacity(0.12))
269+
VStack(spacing: 8) {
270+
if let bannerText = viewModel.cloudSyncBannerText {
271+
BannerLabel(
272+
text: bannerText,
273+
systemImage: "icloud",
274+
foregroundStyle: .orange,
275+
backgroundColor: Color.orange.opacity(0.12)
276+
)
277+
}
278+
279+
if viewModel.saveError?.isWriteScopeRequired == true {
280+
CloudReauthBanner(
281+
isReconnectInFlight: isDropboxReconnectInFlight,
282+
onReconnect: beginDropboxReconnect
283+
)
284+
}
285+
286+
if viewModel.isDirty {
287+
UnsavedChangesBanner()
288+
}
289+
290+
if viewModel.isReadOnly {
291+
ReadOnlyRibbon()
292+
}
268293
}
269294
}
270295
}
296+
.saveConflictAlert(viewModel: viewModel)
297+
.onChange(of: viewModel.saveError) { _, newValue in
298+
if let newValue {
299+
presentedSaveError = newValue
300+
}
301+
}
302+
.alert(item: $presentedSaveError) { error in
303+
Alert(
304+
title: Text("Couldn't Save Database"),
305+
message: Text(error.localizedDescription),
306+
dismissButton: .default(Text("OK"))
307+
)
308+
}
309+
.alert(
310+
"Lock and discard unsaved changes?",
311+
isPresented: Binding(
312+
get: { viewModel.pendingLockRequest != nil },
313+
set: { isPresented in
314+
if isPresented == false {
315+
viewModel.cancelLockRequest()
316+
}
317+
}
318+
)
319+
) {
320+
Button("Lock and Discard", role: .destructive) {
321+
let manuallyTriggered = viewModel.pendingLockRequest?.manuallyTriggered ?? false
322+
viewModel.lockRequest(force: true, manuallyTriggered: manuallyTriggered)
323+
}
324+
Button("Keep Editing", role: .cancel) {
325+
viewModel.cancelLockRequest()
326+
}
327+
} message: {
328+
Text("Your unsaved entry changes will be lost.")
329+
}
330+
}
331+
332+
@MainActor
333+
private func beginDropboxReconnect() {
334+
guard isDropboxReconnectInFlight == false else { return }
335+
guard let provider = CloudProviderRegistry.provider(for: CloudProviderKind.dropbox.rawValue) else {
336+
viewModel.presentSaveError(CloudProviderError.invalidConfiguration)
337+
return
338+
}
339+
340+
isDropboxReconnectInFlight = true
341+
Task { @MainActor in
342+
defer { isDropboxReconnectInFlight = false }
343+
344+
do {
345+
_ = try await provider.authenticate(from: presentationAnchor())
346+
viewModel.clearSaveError()
347+
} catch let error as CloudProviderError where error == .authenticationCancelled {
348+
return
349+
} catch {
350+
viewModel.presentSaveError(error)
351+
}
352+
}
353+
}
354+
355+
@MainActor
356+
private func presentationAnchor() -> ASPresentationAnchor {
357+
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
358+
if let window = scenes.flatMap(\.windows).first(where: \.isKeyWindow) {
359+
return window
360+
}
361+
return ASPresentationAnchor()
362+
}
363+
}
364+
365+
private struct DatabaseSaveToolbarButton: View {
366+
@Bindable var viewModel: DatabaseViewModel
367+
368+
var body: some View {
369+
Button {
370+
Task {
371+
await viewModel.saveHandlingError()
372+
}
373+
} label: {
374+
if viewModel.isSaving {
375+
ProgressView()
376+
.controlSize(.small)
377+
.frame(width: 24, height: 24)
378+
} else {
379+
Image(systemName: "square.and.arrow.down")
380+
}
381+
}
382+
.disabled(viewModel.isDirty == false || viewModel.isSaving)
383+
.accessibilityIdentifier("database.save")
384+
}
385+
}
386+
387+
private struct ReadOnlyRibbon: View {
388+
var body: some View {
389+
Text("Read-only mode — toggle in the database list to enable editing.")
390+
.font(.caption.weight(.medium))
391+
.frame(maxWidth: .infinity, alignment: .leading)
392+
.padding(.horizontal, 16)
393+
.padding(.vertical, 8)
394+
.background(Color.yellow.opacity(0.18))
395+
.accessibilityIdentifier("database.read-only-ribbon")
396+
}
397+
}
398+
399+
private struct UnsavedChangesBanner: View {
400+
var body: some View {
401+
HStack(spacing: 8) {
402+
Circle()
403+
.fill(Color.orange)
404+
.frame(width: 8, height: 8)
405+
Text("Unsaved changes")
406+
.font(.caption.weight(.medium))
407+
}
408+
.frame(maxWidth: .infinity, alignment: .leading)
409+
.padding(.horizontal, 16)
410+
.padding(.vertical, 8)
411+
.background(Color.orange.opacity(0.12))
412+
.accessibilityIdentifier("database.unsaved-indicator")
413+
}
414+
}
415+
416+
private struct CloudReauthBanner: View {
417+
let isReconnectInFlight: Bool
418+
let onReconnect: () -> Void
419+
420+
var body: some View {
421+
VStack(alignment: .leading, spacing: 8) {
422+
Text("Reconnect Dropbox to save changes.")
423+
.font(.subheadline.weight(.semibold))
424+
Button("Reconnect Dropbox", action: onReconnect)
425+
.buttonStyle(.borderedProminent)
426+
.disabled(isReconnectInFlight)
427+
.accessibilityIdentifier("cloud-reauth-banner.reconnect")
428+
}
429+
.frame(maxWidth: .infinity, alignment: .leading)
430+
.padding(16)
431+
.background(
432+
RoundedRectangle(cornerRadius: 16, style: .continuous)
433+
.fill(Color(.secondarySystemBackground))
434+
)
435+
.overlay(
436+
RoundedRectangle(cornerRadius: 16, style: .continuous)
437+
.stroke(Color(.separator), lineWidth: 0.5)
438+
)
439+
.padding(.horizontal, 12)
440+
.accessibilityIdentifier("cloud-reauth-banner")
441+
}
442+
}
443+
444+
private struct BannerLabel: View {
445+
let text: String
446+
let systemImage: String
447+
let foregroundStyle: Color
448+
let backgroundColor: Color
449+
450+
var body: some View {
451+
Label(text, systemImage: systemImage)
452+
.font(.caption.weight(.medium))
453+
.foregroundStyle(foregroundStyle)
454+
.frame(maxWidth: .infinity, alignment: .leading)
455+
.padding(.horizontal, 16)
456+
.padding(.vertical, 10)
457+
.background(backgroundColor)
271458
}
272459
}

KeeForge/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Use this folder as the main map for the app target. The subfolder READMEs hold t
1717
- Database list flow: `App/KeeForgeApp.swift` creates `DatabaseListViewModel`, which reads and mutates persisted database references through `Services/DatabaseListStore.swift`.
1818
- Unlock flow: `Views/UnlockView.swift` drives `ViewModels/DatabaseViewModel.swift`, which resolves the database file, derives the composite key, parses via `Models/KDBXParser.swift`, and stores a per-session `SymmetricKey`.
1919
- Local edit/save flow: `ViewModels/DatabaseViewModel.swift` stages changes in `Models/DatabaseDraft.swift`, reuses `Models/KDBXWriter.swift` for encryption, and saves local files through `Services/LocalDatabaseSaver.swift` with conflict checks, backups, and shared-cache refresh.
20+
- Entry editing flow: `Views/EntryEditView.swift` and `ViewModels/EntryEditViewModel.swift` drive create/edit/delete entry drafts from the unlocked database UI, while `Views/PasswordGeneratorSheet.swift` and `Services/PasswordGenerator.swift` provide the reusable strong-password generator surface.
2021
- Cloud database flow: cloud-backed `Models/DatabaseReference.swift` values carry `CloudSyncMetadata`; `Services/CloudSyncCoordinator.swift` decides whether to reuse cache or download before open.
2122
- Read-only/edit safety flow: `Models/DatabaseReference.swift` persists `isReadOnly` and `editsAcknowledgedAt`; `Services/SyncedFolderDetector.swift` classifies bookmark-backed synced folders before edit flows proceed.
2223
- AutoFill handoff: the main app and extension share models plus selected services through `project.yml`, `SharedVaultStore`, App Group defaults, cached database copies, and Keychain entries; local saves must keep the shared cached copy aligned.

KeeForge/Services/DatabaseListStore.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ enum DatabaseListStore {
1414
private static let uiTestDatabasesJSONEnv = "UI_TEST_DATABASES_JSON"
1515
private static let uiTestCloudDatabasesJSONEnv = "UI_TEST_CLOUD_DATABASES_JSON"
1616
private static let uiTestCloudAccountsJSONEnv = "UI_TEST_CLOUD_ACCOUNTS_JSON"
17+
private static let uiTestLocalSaveConflictCountEnv = "UI_TEST_LOCAL_SAVE_CONFLICT_COUNT"
1718
private static let cloudAccountsStorageKey = "KeeForge.cloudAccounts"
1819

1920
private static var sharedDefaults: UserDefaults {
@@ -30,6 +31,8 @@ enum DatabaseListStore {
3031
}
3132

3233
private nonisolated(unsafe) static var didBootstrapUITesting = false
34+
private nonisolated(unsafe) static var remainingUITestLocalSaveConflicts: Int?
35+
private nonisolated(unsafe) static var consumedUITestLocalSaveConflicts = 0
3336

3437
private struct UITestDatabasePayload: Decodable {
3538
let filename: String
@@ -345,6 +348,27 @@ enum DatabaseListStore {
345348
try? FileManager.default.removeItem(at: backupsRootURL)
346349
activeAutoFillDatabaseID = nil
347350
sharedDefaults.removeObject(forKey: migrationVersionKey)
351+
remainingUITestLocalSaveConflicts = nil
352+
consumedUITestLocalSaveConflicts = 0
353+
}
354+
355+
static func consumeUITestLocalSaveConflictSequence() -> Int? {
356+
guard ProcessInfo.processInfo.arguments.contains(uiTestingLaunchArg) else {
357+
return nil
358+
}
359+
360+
if remainingUITestLocalSaveConflicts == nil {
361+
let rawValue = ProcessInfo.processInfo.environment[uiTestLocalSaveConflictCountEnv] ?? ""
362+
remainingUITestLocalSaveConflicts = max(0, Int(rawValue) ?? 0)
363+
}
364+
365+
guard let remainingUITestLocalSaveConflicts, remainingUITestLocalSaveConflicts > 0 else {
366+
return nil
367+
}
368+
369+
consumedUITestLocalSaveConflicts += 1
370+
self.remainingUITestLocalSaveConflicts = remainingUITestLocalSaveConflicts - 1
371+
return consumedUITestLocalSaveConflicts
348372
}
349373

350374
static func pruneBackups(for reference: DatabaseReference, keeping count: Int) throws {

KeeForge/Services/LocalDatabaseSaver.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,15 @@ enum LocalDatabaseSaver {
178178
}
179179
}
180180

181-
let currentData = try environment.readData(location.url)
181+
var currentData = try environment.readData(location.url)
182+
if let conflictSequence = DatabaseListStore.consumeUITestLocalSaveConflictSequence() {
183+
currentData = try makeUITestConflictData(
184+
from: currentData,
185+
compositeKey: compositeKey,
186+
sequence: conflictSequence
187+
)
188+
try environment.replaceFileAtomically(currentData, location.url)
189+
}
182190
let currentSHA512 = KDBXCrypto.sha512(currentData)
183191
guard currentSHA512 == openTimeSHA512 else {
184192
return .conflict(remoteSHA512: currentSHA512, remoteData: currentData)
@@ -234,6 +242,32 @@ enum LocalDatabaseSaver {
234242
)
235243
}
236244

245+
private static func makeUITestConflictData(
246+
from data: Data,
247+
compositeKey: Data,
248+
sequence: Int
249+
) throws -> Data {
250+
let sessionKey = SymmetricKey(size: .bits256)
251+
let parsed = try KDBXParser.parseWithMetaAndHeader(
252+
data: data,
253+
compositeKey: compositeKey,
254+
sessionKey: sessionKey
255+
)
256+
parsed.rootGroup.entries.append(
257+
KPEntry(
258+
title: "UI Test Conflict \(sequence)",
259+
notes: "Injected save conflict \(sequence)"
260+
)
261+
)
262+
return try KDBXWriter.write(
263+
rootGroup: parsed.rootGroup,
264+
meta: parsed.meta,
265+
compositeKey: compositeKey,
266+
header: parsed.header,
267+
sessionKey: sessionKey
268+
)
269+
}
270+
237271
private static func replaceFileAtomically(_ data: Data, at url: URL) throws {
238272
let fileManager = FileManager.default
239273
let tempURL = url.deletingLastPathComponent().appendingPathComponent(

0 commit comments

Comments
 (0)