From 739d918c217383fb99b7258b37ad5aa190ddbc0e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 21:56:29 -0700 Subject: [PATCH] Add lifecycle remote sync shortcut --- main.go | 66 ++++++++++++++++++++++++- main_test.go | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++ ui_forms.go | 12 +++++ 3 files changed, 213 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 3a6b559..97576fd 100644 --- a/main.go +++ b/main.go @@ -114,6 +114,14 @@ type uiSurface struct { Locked bool } +type lifecycleOpenIntent string + +const ( + lifecycleOpenIntentNone lifecycleOpenIntent = "" + lifecycleOpenIntentRemoteSyncSetup lifecycleOpenIntent = "remote_sync_setup" + lifecycleOpenIntentRemoteSyncSettings lifecycleOpenIntent = "remote_sync_settings" +) + type emptyState struct { Title string Body string @@ -285,6 +293,7 @@ type ui struct { unlockVault widget.Clickable createVault widget.Clickable openVault widget.Clickable + lifecycleRemoteSyncAction widget.Clickable saveVault widget.Clickable saveAsVault widget.Clickable openRemote widget.Clickable @@ -506,6 +515,7 @@ type ui struct { backgroundActionSerial int activeBackgroundAction int lastLifecycleAction string + pendingLifecycleOpenIntent lifecycleOpenIntent requestMasterPassFocus bool invalidate func() } @@ -1070,6 +1080,7 @@ func (u *ui) openVaultAction() error { u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() + u.applyPendingLifecycleOpenIntent() return nil } @@ -1111,11 +1122,49 @@ func (u *ui) startOpenVaultAction() { u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() + u.applyPendingLifecycleOpenIntent() return nil }, nil }) } +func (u *ui) shouldShowLifecycleRemoteSyncAction() bool { + return strings.TrimSpace(u.vaultPath.Text()) != "" +} + +func (u *ui) lifecycleRemoteSyncActionLabel() string { + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Open Vault And Set Up Remote Sync" + } + if hasBoundRecentRemote(u.recentRemotes, path) { + return "Open Vault And Open Remote Sync Settings" + } + return "Open Vault And Set Up Remote Sync" +} + +func (u *ui) beginLifecycleRemoteSyncOpen() { + path := strings.TrimSpace(u.vaultPath.Text()) + switch { + case path == "": + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + case hasBoundRecentRemote(u.recentRemotes, path): + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings + default: + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup + } + u.startOpenVaultAction() +} + +func (u *ui) applyPendingLifecycleOpenIntent() { + intent := u.pendingLifecycleOpenIntent + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + switch intent { + case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings: + u.openRemoteSyncSetupDialog() + } +} + func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err @@ -2568,8 +2617,17 @@ func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bo if path == "" { return recentRemoteRecord{}, false } + return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path) +} + +func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool { + _, ok := boundRecentRemoteForLocalVaultRecords(records, strings.TrimSpace(path)) + return ok +} + +func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) { var matches []recentRemoteRecord - for _, record := range u.recentRemotes { + for _, record := range records { if strings.TrimSpace(record.LocalVaultPath) != path { continue } @@ -3982,6 +4040,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.openVault.Clicked(gtx) { u.startOpenVaultAction() } + for u.lifecycleRemoteSyncAction.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.beginLifecycleRemoteSyncOpen() + } for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) } diff --git a/main_test.go b/main_test.go index 0c95931..3137b5f 100644 --- a/main_test.go +++ b/main_test.go @@ -5988,6 +5988,45 @@ func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) { } } +func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.vaultPath.SetText("/vaults/family.kdbx") + + if !u.shouldShowLifecycleRemoteSyncAction() { + t.Fatal("shouldShowLifecycleRemoteSyncAction() = false, want true with a selected vault") + } + if got := u.lifecycleRemoteSyncActionLabel(); got != "Open Vault And Set Up Remote Sync" { + t.Fatalf("lifecycleRemoteSyncActionLabel() = %q, want setup label", got) + } +} + +func TestUILifecycleRemoteSyncActionLabelUsesSettingsLanguageWithSavedBinding(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) + u.vaultPath.SetText("/vaults/family.kdbx") + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + + if got := u.lifecycleRemoteSyncActionLabel(); got != "Open Vault And Open Remote Sync Settings" { + t.Fatalf("lifecycleRemoteSyncActionLabel() = %q, want settings label", got) + } +} + func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) { t.Parallel() @@ -6110,6 +6149,103 @@ func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) } } +func TestUILifecycleRemoteSyncActionOpensSetupAfterVaultOpen(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + writeKDBXMainTestFile(t, path, vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "bellagio-pass-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(path) + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if !u.syncDialogOpen { + t.Fatal("syncDialogOpen = false, want remote sync setup dialog") + } + if got := u.syncDialogTitle(); got != "Set Up Remote Sync" { + t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got) + } +} + +func TestUILifecycleRemoteSyncActionOpensSettingsAfterVaultOpen(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + writeKDBXMainTestFile(t, path, vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "bellagio-pass-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + { + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + URL: "https://dav.example.invalid/remote.php/dav", + Path: []string{"Crew", "Internet"}, + }, + }, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }, key) + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(path) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if !u.syncDialogOpen { + t.Fatal("syncDialogOpen = false, want remote sync settings dialog") + } + if got := u.syncDialogTitle(); got != "Remote Sync Settings" { + t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got) + } +} + func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 20f1264..57438c1 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -177,6 +177,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return tonedButton(gtx, u.theme, &u.openVault, label) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel()) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")