From b57a671819899cb13230f72b4f17a242f289df9d Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 17:41:44 -0700 Subject: [PATCH] Implement local-first remote sync policy --- main.go | 171 +++++++++++++++++++++++++++++++++++++-- main_test.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++- ui_forms.go | 26 ++++++ 3 files changed, 410 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 04d4286..276d15b 100644 --- a/main.go +++ b/main.go @@ -159,6 +159,7 @@ type recentRemoteRecord struct { LocalVaultPath string `json:"localVaultPath,omitempty"` RemoteProfileID string `json:"remoteProfileId,omitempty"` CredentialEntryID string `json:"credentialEntryId,omitempty"` + SyncMode string `json:"syncMode,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` LastGroup []string `json:"lastGroup,omitempty"` @@ -449,6 +450,7 @@ type ui struct { selectedRemoteConnection bool selectedVaultRemoteProfileID string selectedVaultRemoteCredentialEntryID string + selectedVaultRemoteSyncMode appstate.SyncMode securityDialogOpen bool remotePrefsDialogOpen bool showSyncPassword bool @@ -1010,6 +1012,13 @@ func (u *ui) createVaultAction() error { return err } if u.lifecycleMode == "local" { + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil { return err } @@ -1042,6 +1051,9 @@ func (u *ui) openVaultAction() error { u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + return err + } u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() @@ -1080,6 +1092,9 @@ func (u *ui) startOpenVaultAction() { u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + return err + } u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() @@ -1092,6 +1107,9 @@ func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err } + if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil { + return err + } u.filter() return nil } @@ -1701,6 +1719,7 @@ func (u *ui) loadRecentRemotes() { record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath) record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID) record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + record.SyncMode = strings.TrimSpace(record.SyncMode) record.Username = strings.TrimSpace(record.Username) record.Password = strings.TrimSpace(record.Password) if record.BaseURL == "" || record.Path == "" { @@ -1953,6 +1972,7 @@ func (u *ui) noteRecentRemote(baseURL, path string) { record.LocalVaultPath = binding.LocalVaultPath record.RemoteProfileID = binding.RemoteProfileID record.CredentialEntryID = binding.CredentialEntryID + record.SyncMode = string(binding.SyncMode) } if len(record.LastGroup) == 0 { record.LastGroup = u.recentRemoteGroup(baseURL, path) @@ -2112,6 +2132,7 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath)) u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) u.remotePassword.Mask = '•' u.selectedRemoteConnection = true if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" { @@ -2209,6 +2230,7 @@ func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), RemoteProfileID: profileID, CredentialEntryID: entryID, + SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), }, true } profile, ok := u.selectedVaultRemoteProfile() @@ -2223,9 +2245,29 @@ func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), RemoteProfileID: profile.ID, CredentialEntryID: entry.ID, + SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), }, true } +func normalizeUISyncMode(mode appstate.SyncMode) appstate.SyncMode { + switch mode { + case appstate.SyncModeAutomaticOnOpenSave: + return appstate.SyncModeAutomaticOnOpenSave + default: + return appstate.SyncModeManual + } +} + +func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode { + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + return appstate.SyncModeAutomaticOnOpenSave + } + if u.selectedVaultRemoteSyncMode == "" { + return appstate.SyncModeAutomaticOnOpenSave + } + return appstate.SyncModeManual +} + func (u *ui) syncSavedRemoteBindingSelection() { profiles := u.availableRemoteProfiles() entries := u.availableRemoteCredentialEntries() @@ -2267,6 +2309,22 @@ func (u *ui) syncSavedRemoteBindingSelection() { if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 { u.selectedVaultRemoteCredentialEntryID = entries[0].ID } + if binding, ok := u.selectedVaultRemoteBinding(); ok { + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) { + continue + } + if strings.TrimSpace(record.RemoteProfileID) != strings.TrimSpace(binding.RemoteProfileID) { + continue + } + if strings.TrimSpace(record.CredentialEntryID) != strings.TrimSpace(binding.CredentialEntryID) { + continue + } + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + return + } + } + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual } func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { @@ -2362,7 +2420,7 @@ func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) { Username: username, Password: password, CredentialPath: append([]string(nil), u.currentPath...), - SyncMode: appstate.SyncModeAutomaticOnOpenSave, + SyncMode: u.newRemoteBindingSyncMode(), }, nil } @@ -2377,6 +2435,7 @@ func (u *ui) saveCurrentRemoteBindingAction() error { } u.selectedVaultRemoteProfileID = binding.RemoteProfileID u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode return nil } @@ -2421,6 +2480,7 @@ func (u *ui) materializeCurrentRemoteCache() error { } u.selectedVaultRemoteProfileID = binding.RemoteProfileID u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode return nil } @@ -2894,11 +2954,108 @@ func (u *ui) selectedRemoteUsesLocalCache() bool { strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" } +func (u *ui) currentSessionIsRemote() bool { + session, ok := u.state.Session.(interface{ IsRemote() bool }) + return ok && session.IsRemote() +} + +func (u *ui) resolvedSelectedVaultRemoteBindingForAutoSync() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding() + if err == nil || !ok { + return binding, resolved, ok, err + } + message := err.Error() + if strings.Contains(message, "resolve remote profile:") || strings.Contains(message, "resolve remote credentials:") { + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err +} + +func (u *ui) synchronizeSelectedRemoteBindingOnOpen() error { + if u.currentSessionIsRemote() { + return nil + } + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync() + if err != nil || !ok { + return err + } + if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave { + return nil + } + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + if err := u.state.SynchronizeFromRemote(client, resolved.Profile.Path); err != nil { + return err + } + if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil { + return err + } + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + return nil +} + +func (u *ui) synchronizeSelectedRemoteBindingOnSave() error { + if u.currentSessionIsRemote() { + return nil + } + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync() + if err != nil || !ok { + return err + } + if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave { + return nil + } + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + if err := u.state.SynchronizeToRemote(client, resolved.Profile.Path); err != nil { + return err + } + if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + return nil +} + +func (u *ui) reapplyResolvedRemoteBinding(binding appstate.RemoteBinding, resolved appstate.ResolvedRemoteBinding) error { + _, err := u.state.ConfigureRemoteBinding(appstate.RemoteBindingInput{ + LocalVaultPath: binding.LocalVaultPath, + RemoteProfileID: resolved.Profile.ID, + RemoteProfileName: resolved.Profile.Name, + BaseURL: resolved.Profile.BaseURL, + RemotePath: resolved.Profile.Path, + CredentialEntryID: resolved.Credentials.ID, + CredentialTitle: resolved.Credentials.Title, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + CredentialPath: append([]string(nil), resolved.Credentials.Path...), + SyncMode: binding.SyncMode, + }) + if err != nil { + return err + } + u.selectedVaultRemoteSyncMode = binding.SyncMode + return nil +} + func (u *ui) remoteLifecycleMessage() string { if u.selectedRemoteUsesLocalCache() { return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." } - return "Connect to a remote vault, then unlock it with the KeePass master key." + return "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." } func (u *ui) remoteOpenButtonLabel() string { @@ -2907,20 +3064,24 @@ func (u *ui) remoteOpenButtonLabel() string { if u.selectedRemoteUsesLocalCache() { return "Opening Cached Vault..." } - return "Opening Remote Vault..." + return "Opening Cached Vault Setup..." case u.remoteOpenRetryAvailable(): if u.selectedRemoteUsesLocalCache() { return "Retry Cached Vault" } - return "Retry Remote Vault" + return "Retry Cached Vault Setup" default: if u.selectedRemoteUsesLocalCache() { return "Open Cached Vault" } - return "Open Remote Vault" + return "Open And Cache Vault" } } +func (u *ui) remoteLifecycleSetupSummary() string { + return "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." +} + func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": diff --git a/main_test.go b/main_test.go index 8e94abd..e8490aa 100644 --- a/main_test.go +++ b/main_test.go @@ -2188,6 +2188,14 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) + 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.vaultPath.SetText(path) u.masterPassword.SetText(key.Password) u.selectedVaultRemoteProfileID = "stale-profile" @@ -2211,6 +2219,9 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual from matching recent-remote state", got) + } } func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { @@ -2271,6 +2282,180 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { } } +func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(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: "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://stale.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{ + Entries: []vault.Entry{{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "tjulian" || password != "token-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + localModel.RemoteProfiles[0].BaseURL = server.URL + 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: server.URL, + 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", err) + } + + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if _, err := current.EntryByID("vault-console"); err != nil { + t.Fatalf("EntryByID(vault-console) error = %v, want remote entry merged on open", err) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) + } +} + +func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(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: "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://stale.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + var ( + savedRemote []byte + putCount int + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "tjulian" || password != "token-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-1, true)", username, password, ok) + } + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", "\"v1\"") + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + putCount++ + var err error + savedRemote, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + w.Header().Set("ETag", "\"v2\"") + w.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + localModel.RemoteProfiles[0].BaseURL = server.URL + 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: server.URL, + 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", err) + } + if err := u.state.UpsertEntry(vault.Entry{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + if err := u.saveAction(); err != nil { + t.Fatalf("saveAction() error = %v", err) + } + + if putCount == 0 { + t.Fatal("remote PUT count = 0, want automatic remote synchronize on save") + } + loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedRemote), key) + if err != nil { + t.Fatalf("LoadKDBXWithKey(savedRemote) error = %v", err) + } + if _, err := loaded.EntryByID("entry-1"); err != nil { + t.Fatalf("EntryByID(entry-1) error = %v, want saved entry on remote", err) + } +} + func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { t.Parallel() @@ -4510,6 +4695,7 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { first.vaultPath.SetText("/vaults/family.kdbx") first.selectedVaultRemoteProfileID = "remote-profile-1" first.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}) @@ -4530,6 +4716,9 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { if record.CredentialEntryID != "remote-creds-1" { t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID) } + if record.SyncMode != string(appstate.SyncModeAutomaticOnOpenSave) { + t.Fatalf("recentRemotes[0].SyncMode = %q, want automatic_on_open_save", record.SyncMode) + } } func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) { @@ -4611,6 +4800,7 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) { LocalVaultPath: "/vaults/family.kdbx", RemoteProfileID: "remote-profile-1", CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }) if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" { @@ -4622,6 +4812,9 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) { if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) + } } func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) { @@ -5829,13 +6022,13 @@ func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" - if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" { - t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault") + if got := u.remoteOpenButtonLabel(); got != "Open And Cache Vault" { + t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open And Cache Vault") } u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" - if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" { - t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault") + if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault Setup" { + t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault Setup") } } @@ -5857,6 +6050,27 @@ func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing } } +func TestUIRemoteLifecycleMessageUsesLocalFirstSetupLanguageForFirstRemoteOpen(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + + if got := u.remoteLifecycleMessage(); got != "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." { + t.Fatalf("remoteLifecycleMessage() = %q, want local-first remote setup guidance", got) + } +} + +func TestUIRemoteLifecycleSetupSummaryExplainsCacheAndBindingFlow(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteLifecycleSetupSummary(); got != "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." { + t.Fatalf("remoteLifecycleSetupSummary() = %q, want local-cache bootstrap guidance", got) + } +} + func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 750d8f6..7be0422 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -169,6 +169,32 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showRemoteChooser { + return layout.Dimensions{} + } + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), "Local Cache Bootstrap") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), u.remoteLifecycleSetupSummary()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showRemoteChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx) + }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx,