From 8866d9c06f9a6963d7cdd45bf9f56b5c11e080b8 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 18:01:21 -0700 Subject: [PATCH] Handle local-open remote sync fallback --- main.go | 52 +++++++++++++++++++++++++++++++-- main_test.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 276d15b..739d38b 100644 --- a/main.go +++ b/main.go @@ -1052,7 +1052,7 @@ func (u *ui) openVaultAction() error { u.restoreRecentVaultGroup(path) u.syncSavedRemoteBindingSelection() if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { - return err + u.showStatusMessage("Remote sync on open failed: " + err.Error()) } u.loadSecuritySettingsFromSession() u.editingEntry = false @@ -1093,7 +1093,7 @@ func (u *ui) startOpenVaultAction() { u.restoreRecentVaultGroup(path) u.syncSavedRemoteBindingSelection() if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { - return err + u.showStatusMessage("Remote sync on open failed: " + err.Error()) } u.loadSecuritySettingsFromSession() u.editingEntry = false @@ -2309,6 +2309,17 @@ func (u *ui) syncSavedRemoteBindingSelection() { if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 { u.selectedVaultRemoteCredentialEntryID = entries[0].ID } + if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" { + if record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())); ok { + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } + } + } if binding, ok := u.selectedVaultRemoteBinding(); ok { for _, record := range u.recentRemotes { if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) { @@ -2327,6 +2338,27 @@ func (u *ui) syncSavedRemoteBindingSelection() { u.selectedVaultRemoteSyncMode = appstate.SyncModeManual } +func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { + path = strings.TrimSpace(path) + if path == "" { + return recentRemoteRecord{}, false + } + var matches []recentRemoteRecord + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) != path { + continue + } + if strings.TrimSpace(record.RemoteProfileID) == "" || strings.TrimSpace(record.CredentialEntryID) == "" { + continue + } + matches = append(matches, record) + } + if len(matches) != 1 { + return recentRemoteRecord{}, false + } + return matches[0], true +} + func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { profiles := u.availableRemoteProfiles() entries := u.availableRemoteCredentialEntries() @@ -7094,5 +7126,19 @@ func runFilePicker(name string, args ...string) (string, error) { if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return parsePickedFilePath(output) +} + +func parsePickedFilePath(output []byte) (string, error) { + lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") { + return line, nil + } + } + return "", fmt.Errorf("file picker did not return a path") } diff --git a/main_test.go b/main_test.go index e8490aa..96415dc 100644 --- a/main_test.go +++ b/main_test.go @@ -1447,7 +1447,13 @@ func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) { t.Fatalf("WriteFile(updated.key) error = %v", err) } - u := newUIWithSession("desktop", &session.Manager{}) + stateDir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(stateDir, "default.kdbx"), + RecentVaultsPath: filepath.Join(stateDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(stateDir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(stateDir, "ui-prefs.json"), + }) u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) u.masterPassword.SetText("old-password") if err := u.createVaultAction(); err != nil { @@ -2360,6 +2366,62 @@ func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing. } } +func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Local Cache", Path: []string{"Root", "Internet"}}, + {ID: "remote-creds-1", Title: "WebDAV Sign-In", Username: "tjulian", Password: "token-1", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://unreachable.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://unreachable.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v, want local open to succeed even if auto-sync fails", err) + } + + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if _, err := current.EntryByID("entry-1"); err != nil { + t.Fatalf("EntryByID(entry-1) error = %v, want local vault opened", err) + } + if got := u.state.StatusMessage; !strings.Contains(got, "Remote sync on open failed:") { + t.Fatalf("StatusMessage = %q, want nonfatal remote sync failure notice", got) + } + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage = %q, want empty for nonfatal remote sync failure", got) + } +} + func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { t.Parallel() @@ -2456,6 +2518,24 @@ func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { } } +func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) { + t.Parallel() + + output := strings.Join([]string{ + "(zenity:1): Gdk-DEBUG: Ignoring portal setting", + "/home/tester/vaults/family.kdbx", + "", + }, "\n") + + got, err := parsePickedFilePath([]byte(output)) + if err != nil { + t.Fatalf("parsePickedFilePath() error = %v", err) + } + if got != "/home/tester/vaults/family.kdbx" { + t.Fatalf("parsePickedFilePath() = %q, want /home/tester/vaults/family.kdbx", got) + } +} + func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { t.Parallel()