package appui import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "slices" "strings" "time" "gioui.org/widget" "git.julianfamily.org/keepassgo/internal/appstate" "git.julianfamily.org/keepassgo/internal/autofillcache" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vaultview" "git.julianfamily.org/keepassgo/internal/webdav" ) func (u *ui) noteRecentVault(path string) { path = strings.TrimSpace(path) if path == "" { return } if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } if u.recentVaultUsedAt == nil { u.recentVaultUsedAt = map[string]time.Time{} } if len(u.currentPath) > 0 { u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) } else if _, ok := u.recentVaultGroups[path]; !ok { u.recentVaultGroups[path] = nil } u.recentVaultUsedAt[path] = u.now() next := []string{path} for _, existing := range u.recentVaults { if existing == path { continue } next = append(next, existing) if len(next) == 6 { break } } u.recentVaults = next if len(u.recentVaultClicks) < len(u.recentVaults) { u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) } u.saveRecentVaults() } func (u *ui) loadRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return } content, err := os.ReadFile(u.recentVaultsPath) if err != nil { return } u.recentVaults = nil u.recentVaultGroups = map[string][]string{} u.recentVaultUsedAt = map[string]time.Time{} var records []recentVaultRecord switch { case json.Unmarshal(content, &records) == nil: u.applyRecentVaultRecords(records) return default: var paths []string if err := json.Unmarshal(content, &paths); err != nil { return } records = make([]recentVaultRecord, 0, len(paths)) for _, path := range paths { records = append(records, recentVaultRecord{Path: path}) } u.applyRecentVaultRecords(records) } } func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) { filtered := make([]string, 0, len(records)) seen := map[string]bool{} for _, record := range records { path := strings.TrimSpace(record.Path) if path == "" || seen[path] { continue } seen[path] = true filtered = append(filtered, path) if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } if u.recentVaultUsedAt == nil { u.recentVaultUsedAt = map[string]time.Time{} } u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...) if usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)); err == nil { u.recentVaultUsedAt[path] = usedAt } if len(filtered) == 6 { break } } u.recentVaults = filtered if len(u.recentVaultClicks) < len(u.recentVaults) { u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) } } func (u *ui) loadRecentRemotes() { if strings.TrimSpace(u.recentRemotesPath) == "" { return } content, err := os.ReadFile(u.recentRemotesPath) if err != nil { return } var records []recentRemoteRecord if err := json.Unmarshal(content, &records); err != nil { return } filtered := make([]recentRemoteRecord, 0, len(records)) seen := map[string]bool{} for _, record := range records { record.BaseURL = strings.TrimSpace(record.BaseURL) record.Path = strings.TrimSpace(record.Path) 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 == "" { continue } if record.Username != "" || record.Password != "" { record.NeedsMigration = true record.Username = "" record.Password = "" } key := record.BaseURL + "|" + record.Path if seen[key] { continue } seen[key] = true record.LastGroup = append([]string(nil), record.LastGroup...) filtered = append(filtered, record) if len(filtered) == 6 { break } } u.recentRemotes = filtered if len(u.recentRemoteClicks) < len(u.recentRemotes) { u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) } } func (u *ui) hasLegacyRecentRemoteCredentialMigration() bool { for _, record := range u.recentRemotes { if record.NeedsMigration { return true } } return false } func (u *ui) saveRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil { return } records := make([]recentVaultRecord, 0, len(u.recentVaults)) for _, path := range u.recentVaults { records = append(records, recentVaultRecord{ Path: path, LastGroup: append([]string(nil), u.recentVaultGroups[path]...), UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano), }) } content, err := json.MarshalIndent(records, "", " ") if err != nil { return } _ = os.WriteFile(u.recentVaultsPath, content, 0o600) } func (u *ui) saveRecentRemotes() { if strings.TrimSpace(u.recentRemotesPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil { return } content, err := json.MarshalIndent(u.recentRemotes, "", " ") if err != nil { return } _ = os.WriteFile(u.recentRemotesPath, content, 0o600) } func (u *ui) loadUIPreferences() { if strings.TrimSpace(u.uiPreferencesPath) == "" { return } content, err := os.ReadFile(u.uiPreferencesPath) if err != nil { return } var prefs uiPreferences if err := json.Unmarshal(content, &prefs); err != nil { return } u.groupControlsHidden = prefs.GroupControlsHidden u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden u.historyHidden = prefs.HistoryHidden u.denseLayout = prefs.DenseLayout u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) displayDensity := strings.TrimSpace(prefs.DisplayDensity) if displayDensity == "" { displayDensity = displayDensityForDenseLayout(prefs.DenseLayout) } u.applyAccessibilityPreferences(accessibilityPreferences{ DisplayDensity: displayDensity, Contrast: prefs.Contrast, ReducedMotion: prefs.ReducedMotion, KeyboardFocus: prefs.KeyboardFocus, }) if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" { u.autofillFirstFillApprovalMode = mode } u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist)) u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist)) u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules)) } func (u *ui) saveUIPreferences() { if strings.TrimSpace(u.uiPreferencesPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil { return } content, err := json.MarshalIndent(uiPreferences{ GroupControlsHidden: u.groupControlsHidden, LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, HistoryHidden: u.historyHidden, DenseLayout: u.denseLayout, StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), AutofillNoticeMode: string(u.autofillNoticePreference), DisplayDensity: u.accessibilityPrefs.DisplayDensity, Contrast: u.accessibilityPrefs.Contrast, ReducedMotion: u.accessibilityPrefs.ReducedMotion, KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, AutofillPrivacy: autofillPrivacySettings{ FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode), BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()), AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()), PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()), }, }, "", " ") if err != nil { return } _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) } func (u *ui) loadSettingsFormFromPreferences() { u.settingsGroupControls.Value = u.groupControlsHidden u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden u.settingsHistory.Value = u.historyHidden u.settingsDenseLayout.Value = u.denseLayout } func (u *ui) applySettingsFormToPreferences() { u.groupControlsHidden = u.settingsGroupControls.Value u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value u.historyHidden = u.settingsHistory.Value u.denseLayout = u.settingsDenseLayout.Value } func normalizedStatusBannerTTL(valueMillis int) time.Duration { switch { case valueMillis <= 0: return statusBannerDuration case time.Duration(valueMillis)*time.Millisecond > statusBannerLong: return statusBannerLong default: return time.Duration(valueMillis) * time.Millisecond } } func normalizedAutofillNoticeMode(value string) autofillNoticeMode { switch autofillNoticeMode(strings.TrimSpace(value)) { case autofillNoticeApprovals: return autofillNoticeApprovals case autofillNoticeSuppressed: return autofillNoticeSuppressed default: return autofillNoticeAll } } func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode { switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) { case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock: return autofillFirstFillApprovalMode(strings.TrimSpace(raw)) default: return "" } } func autofillPrivacyLines(text string) []string { lines := strings.Split(text, "\n") result := make([]string, 0, len(lines)) seen := make(map[string]struct{}, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if _, ok := seen[line]; ok { continue } seen[line] = struct{}{} result = append(result, line) } return result } func joinAutofillPrivacyLines(lines []string) string { if len(lines) == 0 { return "" } return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n") } func (u *ui) autofillRuleCount() int { return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) + len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) + len(autofillPrivacyLines(u.autofillPackageRules.Text())) } func (u *ui) autofillFirstFillApprovalSummary() string { switch u.autofillFirstFillApprovalMode { case autofillFirstFillApprovalAllow: return "New apps and packages can fill immediately until a persistent rule is created." case autofillFirstFillApprovalBlock: return "New apps and packages stay blocked until you add an allowlist entry or a package rule." default: return "KeePassGO asks before the first fill into a newly seen app or package." } } func (u *ui) setStatusBannerTTL(value time.Duration) { u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) u.saveUIPreferences() } func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value)) u.saveUIPreferences() } func (u *ui) noteRecentRemote(baseURL, path string) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) if baseURL == "" || path == "" { return } record := recentRemoteRecord{ BaseURL: baseURL, Path: path, LastGroup: append([]string(nil), u.currentPath...), UsedAt: u.now().Format(time.RFC3339Nano), } if binding, ok := u.selectedVaultRemoteBinding(); ok { 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) } next := []recentRemoteRecord{record} for _, existing := range u.recentRemotes { if existing.BaseURL == baseURL && existing.Path == path { continue } next = append(next, existing) if len(next) == 6 { break } } u.recentRemotes = next if len(u.recentRemoteClicks) < len(u.recentRemotes) { u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) } u.saveRecentRemotes() } func (u *ui) recentRemoteGroup(baseURL, path string) []string { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) for _, record := range u.recentRemotes { if record.BaseURL == baseURL && record.Path == path { return append([]string(nil), record.LastGroup...) } } return nil } func (u *ui) restoreStartupLifecycleTarget() { localPath, localUsedAt := u.latestRecentVault() remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() switch { case hasRemote && strings.TrimSpace(remoteRecord.LocalVaultPath) != "" && (localPath == "" || remoteUsedAt.After(localUsedAt)): u.lifecycleMode = "local" u.vaultPath.SetText(strings.TrimSpace(remoteRecord.LocalVaultPath)) case localPath != "": u.lifecycleMode = "local" u.vaultPath.SetText(localPath) case hasRemote: u.lifecycleMode = "remote" u.applyRecentRemoteRecord(remoteRecord) } } func (u *ui) hasSelectedLifecycleTarget() bool { switch strings.TrimSpace(u.lifecycleMode) { case "remote": return u.hasSelectedRemoteTarget() default: return strings.TrimSpace(u.vaultPath.Text()) != "" } } func (u *ui) hasSelectedRemoteTarget() bool { return u.selectedRemoteConnection } func (u *ui) latestRecentVault() (string, time.Time) { for _, path := range u.recentVaults { if strings.TrimSpace(path) == "" { continue } return path, u.recentVaultUsedAt[path] } return "", time.Time{} } func (u *ui) hasSelectedVaultPath() bool { return strings.TrimSpace(u.vaultPath.Text()) != "" } func (u *ui) showLocalVaultChooser() bool { return u.lifecycleMode != "local" || !u.hasSelectedVaultPath() } func (u *ui) showRemoteConnectionChooser() bool { return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget() } func (u *ui) switchToLifecycleSelection(mode string) { u.state.Session = &session.Manager{} u.state.CurrentPath = nil u.state.SelectedEntryID = "" u.state.Section = appstate.SectionEntries u.state.Dirty = false u.state.ErrorMessage = "" u.state.StatusMessage = "" u.loadingMessage = "" u.loadingActionLabel = "" u.lastLifecycleAction = "" u.lifecycleMode = mode u.editingEntry = false u.currentPath = nil u.syncedPath = nil u.clearMasterPassword() u.keyFilePath.SetText("") u.search.SetText("") switch mode { case "remote": u.vaultPath.SetText("") u.remoteBaseURL.SetText("") u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") u.selectedRemoteConnection = false default: u.vaultPath.SetText("") u.remoteBaseURL.SetText("") u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") u.selectedRemoteConnection = false } u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.filter() } func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { for _, record := range u.recentRemotes { if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { continue } usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)) if err != nil { usedAt = time.Time{} } return record, true, usedAt } return recentRemoteRecord{}, false, time.Time{} } func (u *ui) currentRemoteRecord() recentRemoteRecord { return recentRemoteRecord{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Path: strings.TrimSpace(u.remotePath.Text()), } } func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) 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) == "" { u.showStatusMessage("This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it.") } } func (u *ui) remotePreferencesCurrentSummary() string { switch { case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": return "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." default: return "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." } } func (u *ui) remotePreferencesAlwaysSavedSummary() string { return "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." } func (u *ui) remotePreferencesRetentionSummary() string { return "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." } func (u *ui) remotePreferencesPersistenceSummary() string { return "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." } func (u *ui) availableRemoteProfiles() []vault.RemoteProfile { profiles, err := u.state.RemoteProfiles() if err != nil { return nil } return profiles } func (u *ui) availableRemoteCredentialEntries() []vault.Entry { entries, err := u.state.RemoteCredentialEntries() if err != nil { return nil } return entries } func normalizeRemoteCredentialURL(raw string) string { raw = strings.TrimSpace(raw) raw = strings.TrimRight(raw, "/") return raw } func remoteCredentialURLMatches(candidate, target string) bool { candidate = normalizeRemoteCredentialURL(candidate) target = normalizeRemoteCredentialURL(target) if candidate == "" || target == "" { return false } if candidate == target { return true } candidateURL, err := url.Parse(candidate) if err != nil { return false } targetURL, err := url.Parse(target) if err != nil { return false } if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) { return false } candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/") targetPath := strings.TrimRight(targetURL.EscapedPath(), "/") if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" { return true } return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath) } func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote { return nil } baseURL := normalizeRemoteCredentialURL(u.syncRemoteBaseURL.Text()) if baseURL == "" { return nil } remotePath := strings.TrimSpace(u.syncRemotePath.Text()) entries := u.availableRemoteCredentialEntries() byID := u.remoteCredentialEntryMap(entries) matches := make([]vault.Entry, 0, len(entries)) seen := make(map[string]struct{}, len(entries)) appendMatch := func(entry vault.Entry) { u.appendRemoteCredentialMatch(&matches, seen, entry) } u.appendURLMatchedRemoteCredentials(baseURL, entries, appendMatch) profilesByID := u.remoteProfileMap() localVaultPath := strings.TrimSpace(u.vaultPath.Text()) for _, record := range u.recentRemotes { if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath { continue } profile, ok := profilesByID[strings.TrimSpace(record.RemoteProfileID)] if !ok { continue } if !remoteCredentialURLMatches(profile.BaseURL, baseURL) { continue } if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath { continue } entry, ok := byID[strings.TrimSpace(record.CredentialEntryID)] if !ok { continue } appendMatch(entry) } return matches } func (u *ui) validRemoteProfileSelection(profiles []vault.RemoteProfile) string { selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) if u.hasRemoteProfileSelection(selectedID, profiles) { return selectedID } if len(profiles) == 1 { return profiles[0].ID } return "" } func (u *ui) validRemoteCredentialSelection(entries []vault.Entry) string { selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) if u.hasRemoteCredentialSelection(selectedID, entries) { return selectedID } if len(entries) == 1 { return entries[0].ID } return "" } func (u *ui) hasRemoteProfileSelection(selectedID string, profiles []vault.RemoteProfile) bool { for _, profile := range profiles { if profile.ID == selectedID { return true } } return false } func (u *ui) hasRemoteCredentialSelection(selectedID string, entries []vault.Entry) bool { for _, entry := range entries { if entry.ID == selectedID { return true } } return false } func (u *ui) applySelectedRemoteProfileFields() { if profile, ok := u.selectedVaultRemoteProfile(); ok { u.remoteBaseURL.SetText(profile.BaseURL) u.remotePath.SetText(profile.Path) } } func (u *ui) syncRecentRemoteBindingSelection() { if strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" { return } record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())) if !ok { return } u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) u.applySelectedRemoteProfileFields() } func (u *ui) syncSelectedRemoteBindingMode() { binding, ok := u.selectedVaultRemoteBinding() if !ok { u.selectedVaultRemoteSyncMode = appstate.SyncModeManual return } for _, record := range u.recentRemotes { if strings.TrimSpace(record.LocalVaultPath) == strings.TrimSpace(binding.LocalVaultPath) && strings.TrimSpace(record.RemoteProfileID) == strings.TrimSpace(binding.RemoteProfileID) && strings.TrimSpace(record.CredentialEntryID) == strings.TrimSpace(binding.CredentialEntryID) { u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) return } } u.selectedVaultRemoteSyncMode = appstate.SyncModeManual } func (u *ui) remoteCredentialEntryMap(entries []vault.Entry) map[string]vault.Entry { byID := make(map[string]vault.Entry, len(entries)) for _, entry := range entries { byID[entry.ID] = entry } return byID } func (u *ui) remoteProfileMap() map[string]vault.RemoteProfile { profilesByID := make(map[string]vault.RemoteProfile) for _, profile := range u.availableRemoteProfiles() { profilesByID[profile.ID] = profile } return profilesByID } func (u *ui) appendRemoteCredentialMatch(matches *[]vault.Entry, seen map[string]struct{}, entry vault.Entry) { if strings.TrimSpace(entry.ID) == "" { return } if _, ok := seen[entry.ID]; ok { return } seen[entry.ID] = struct{}{} *matches = append(*matches, entry) } func (u *ui) appendURLMatchedRemoteCredentials(baseURL string, entries []vault.Entry, appendMatch func(vault.Entry)) { for _, entry := range entries { if remoteCredentialURLMatches(entry.URL, baseURL) { appendMatch(entry) } } } func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) { u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID) u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username)) u.syncRemotePassword.SetText(entry.Password) } func (u *ui) savedAdvancedSyncRemoteBinding() (appstate.ResolvedRemoteBinding, bool) { if !u.hasOpenVault() { return appstate.ResolvedRemoteBinding{}, false } _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding() if err != nil || !ok { return appstate.ResolvedRemoteBinding{}, false } return resolved, true } func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() { resolved, ok := u.savedAdvancedSyncRemoteBinding() if !ok { return } u.syncRemoteBaseURL.SetText(resolved.Profile.BaseURL) u.syncRemotePath.SetText(resolved.Profile.Path) u.syncRemoteUsername.SetText(resolved.Credentials.Username) u.syncRemotePassword.SetText(resolved.Credentials.Password) u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(resolved.Credentials.ID) } func (u *ui) syncDialogTitle() string { switch { case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: if _, ok := u.selectedVaultRemoteBinding(); ok { return "Remote Sync Settings" } return "Set Up Remote Sync" default: return "Advanced Sync" } } func (u *ui) syncDialogDescription() string { switch { case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: if _, ok := u.selectedVaultRemoteBinding(); ok { return "Review or change this vault's saved WebDAV target, credentials, and sync mode." } return "Send this local vault to a WebDAV target, then use that target for future sync." default: return "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings." } } func (u *ui) syncDialogConfirmButtonLabel() string { switch { case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: if _, ok := u.selectedVaultRemoteBinding(); ok { return "Save Remote Sync Settings" } return "Set Up Remote Sync" default: return "Synchronize" } } func (u *ui) shouldShowSyncDirectionChoices() bool { return u.syncDialogPurpose != syncDialogPurposeRemoteSetup } func (u *ui) shouldShowSyncSourceChoices() bool { return u.syncDialogPurpose != syncDialogPurposeRemoteSetup } func (u *ui) syncSetupMode() appstate.SyncMode { if u.syncSetupAutomatic.Value { return appstate.SyncModeAutomaticOnOpenSave } return appstate.SyncModeManual } func (u *ui) selectVaultRemoteProfile(id string) { u.selectedVaultRemoteProfileID = strings.TrimSpace(id) u.applySelectedRemoteProfileFields() } func (u *ui) selectVaultRemoteCredentialEntry(id string) { u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id) } func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) { profiles := u.availableRemoteProfiles() id := u.validRemoteProfileSelection(profiles) if id == "" { return vault.RemoteProfile{}, false } for _, profile := range profiles { if profile.ID == id { return profile, true } } return vault.RemoteProfile{}, false } func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) { entries := u.availableRemoteCredentialEntries() id := u.validRemoteCredentialSelection(entries) if id == "" { return vault.Entry{}, false } for _, entry := range entries { if entry.ID == id { return entry, true } } return vault.Entry{}, false } func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { localVaultPath := strings.TrimSpace(u.vaultPath.Text()) profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) if profileID == "" { profileID = u.validRemoteProfileSelection(u.availableRemoteProfiles()) } credentialID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) if credentialID == "" { credentialID = u.validRemoteCredentialSelection(u.availableRemoteCredentialEntries()) } if profileID == "" || credentialID == "" { return appstate.RemoteBinding{}, false } if localVaultPath == "" { for _, record := range u.recentRemotes { if strings.TrimSpace(record.RemoteProfileID) == profileID && strings.TrimSpace(record.CredentialEntryID) == credentialID && strings.TrimSpace(record.LocalVaultPath) != "" { localVaultPath = strings.TrimSpace(record.LocalVaultPath) break } } } if localVaultPath == "" { localVaultPath, _ = u.latestRecentVault() } return appstate.RemoteBinding{ LocalVaultPath: localVaultPath, RemoteProfileID: profileID, CredentialEntryID: credentialID, 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 u.syncDialogPurpose == syncDialogPurposeRemoteSetup { return u.syncSetupMode() } return normalizeUISyncMode(u.selectedVaultRemoteSyncMode) } func (u *ui) syncSavedRemoteBindingSelection() { profiles := u.availableRemoteProfiles() entries := u.availableRemoteCredentialEntries() u.syncRecentRemoteBindingSelection() u.selectedVaultRemoteProfileID = u.validRemoteProfileSelection(profiles) u.selectedVaultRemoteCredentialEntryID = u.validRemoteCredentialSelection(entries) u.syncSelectedRemoteBindingMode() u.applySelectedRemoteProfileFields() } func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path) } func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool { _, ok := boundRecentRemoteForLocalVaultRecords(records, path) return ok } func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) { path = strings.TrimSpace(path) if path == "" { return recentRemoteRecord{}, false } for _, record := range records { if strings.TrimSpace(record.LocalVaultPath) == path && strings.TrimSpace(record.RemoteProfileID) != "" && strings.TrimSpace(record.CredentialEntryID) != "" { return record, true } } return recentRemoteRecord{}, false } func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { profiles := u.availableRemoteProfiles() entries := u.availableRemoteCredentialEntries() if len(profiles) == 0 || len(entries) == 0 { return false } return len(profiles) > 1 || len(entries) > 1 } func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) { summary := u.computeSavedRemoteBindingSummary() return summary.ProfileLabel, summary.CredentialLabel, summary.SyncLabel, summary.OK } func (u *ui) savedRemoteBindingHeading() string { return u.buildSyncMenuModel().SavedBindingHeading() } func (u *ui) openSelectedVaultRemoteButtonLabel() string { return u.buildSyncMenuModel().OpenSelectedButtonLabel() } func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } return u.buildSyncMenuModel().ShowDirectRemoteSyncShortcut() } func (u *ui) directRemoteSyncShortcutLabel() string { return u.buildSyncMenuModel().DirectRemoteSyncShortcutLabel() } func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } return u.buildSyncMenuModel().ShowRemoteSyncSettingsShortcut() } func (u *ui) remoteSyncSettingsShortcutLabel() string { return u.buildSyncMenuModel().RemoteSyncSettingsShortcutLabel() } func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool { return u.buildSyncMenuModel().ShowRemoveRemoteSyncShortcut() } func (u *ui) removeRemoteSyncShortcutLabel() string { return u.buildSyncMenuModel().RemoveRemoteSyncShortcutLabel() } func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } return u.buildSyncMenuModel().ShowRemoteSyncSetupShortcut() } func (u *ui) remoteSyncSetupShortcutLabel() string { return u.buildSyncMenuModel().RemoteSyncSetupShortcutLabel() } func (u *ui) syncMenuActionLabels() []string { return u.buildSyncMenuModel().ActionLabels() } func remoteBindingSuffix(baseURL, path, username string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username))) return hex.EncodeToString(sum[:8]) } func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) { baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) remotePath := strings.TrimSpace(u.remotePath.Text()) username := strings.TrimSpace(u.remoteUsername.Text()) password := u.remotePassword.Text() localVaultPath := strings.TrimSpace(u.vaultPath.Text()) switch { case localVaultPath == "": return appstate.RemoteBindingInput{}, fmt.Errorf("local vault path is required") case baseURL == "": return appstate.RemoteBindingInput{}, fmt.Errorf("remote base URL is required") case remotePath == "": return appstate.RemoteBindingInput{}, fmt.Errorf("remote path is required") case username == "": return appstate.RemoteBindingInput{}, fmt.Errorf("remote username is required") case password == "": return appstate.RemoteBindingInput{}, fmt.Errorf("remote password is required") } suffix := remoteBindingSuffix(baseURL, remotePath, username) credentialTitle := "WebDAV Sign-In" if username != "" { credentialTitle += " · " + username } return appstate.RemoteBindingInput{ LocalVaultPath: localVaultPath, RemoteProfileID: "remote-profile-" + suffix, RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), BaseURL: baseURL, RemotePath: remotePath, CredentialEntryID: "remote-credential-" + suffix, CredentialTitle: credentialTitle, Username: username, Password: password, CredentialPath: append([]string(nil), u.currentPath...), SyncMode: u.newRemoteBindingSyncMode(), }, nil } func (u *ui) saveCurrentRemoteBindingAction() error { input, err := u.currentRemoteBindingInput() if err != nil { return err } binding, err := u.state.ConfigureRemoteBinding(input) if err != nil { return err } u.selectedVaultRemoteProfileID = binding.RemoteProfileID u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID u.selectedVaultRemoteSyncMode = binding.SyncMode return nil } func (u *ui) stripRecentRemoteBinding(binding appstate.RemoteBinding) { localPath := strings.TrimSpace(binding.LocalVaultPath) profileID := strings.TrimSpace(binding.RemoteProfileID) credentialID := strings.TrimSpace(binding.CredentialEntryID) for i := range u.recentRemotes { record := &u.recentRemotes[i] if strings.TrimSpace(record.LocalVaultPath) != localPath { continue } if strings.TrimSpace(record.RemoteProfileID) != profileID { continue } if strings.TrimSpace(record.CredentialEntryID) != credentialID { continue } record.LocalVaultPath = "" record.RemoteProfileID = "" record.CredentialEntryID = "" record.SyncMode = "" } } func (u *ui) removeSelectedRemoteBindingAction() error { binding, ok := u.selectedVaultRemoteBinding() if !ok { return fmt.Errorf("no saved remote sync target is selected") } if err := u.state.RemoveRemoteBinding(binding); err != nil { return err } if err := u.state.Save(); err != nil { return err } u.stripRecentRemoteBinding(binding) u.selectedVaultRemoteProfileID = "" u.selectedVaultRemoteCredentialEntryID = "" u.selectedVaultRemoteSyncMode = appstate.SyncModeManual u.remoteUsername.SetText("") u.remotePassword.SetText("") u.showStatusMessage("Remote sync is no longer set up for this vault.") return nil } func (u *ui) saveCurrentRemoteBindingHeading() string { return u.buildSyncMenuModel().SaveCurrentRemoteBindingHeading() } func (u *ui) saveCurrentRemoteBindingButtonLabel() string { return u.buildSyncMenuModel().SaveCurrentRemoteBindingButtonLabel() } func (u *ui) materializeCurrentRemoteCache() error { cachePath := strings.TrimSpace(u.vaultPath.Text()) if cachePath == "" { cachePath = u.saveAsTargetPath() } if cachePath == "" { return nil } u.vaultPath.SetText(cachePath) if err := u.state.SaveAs(cachePath); err != nil { return err } u.noteRecentVault(cachePath) username := strings.TrimSpace(u.remoteUsername.Text()) password := u.remotePassword.Text() if username == "" && password == "" { return nil } input, err := u.currentRemoteBindingInput() if err != nil { return err } binding, err := u.state.ConfigureRemoteBinding(input) if err != nil { return err } if err := u.state.SaveAs(cachePath); err != nil { return err } u.selectedVaultRemoteProfileID = binding.RemoteProfileID u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID u.selectedVaultRemoteSyncMode = binding.SyncMode return nil } func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { if u.hasOpenVault() { return u.resolvedSelectedVaultRemoteBinding() } binding, ok := u.selectedVaultRemoteBinding() if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil } if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err } u.vaultPath.SetText(binding.LocalVaultPath) u.noteRecentVault(binding.LocalVaultPath) u.restoreRecentVaultGroup(binding.LocalVaultPath) model, err := u.state.Session.Current() if err != nil { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err } resolved, err := binding.Resolve(model) if err != nil { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err } return binding, resolved, true, nil } func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { binding, ok := u.selectedVaultRemoteBinding() if !ok { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil } model, err := u.state.Session.Current() if err != nil { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err } resolved, err := binding.Resolve(model) if err != nil { return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err } return binding, resolved, true, nil } func (u *ui) noteCurrentRemotePath() { status, ok := u.state.Session.(sessionStatus) if !ok || !status.IsRemote() || status.IsLocked() { return } baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) if baseURL == "" || path == "" { return } for i := range u.recentRemotes { if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path { continue } u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...) u.saveRecentRemotes() return } } func (u *ui) recentVaultGroup(path string) []string { if u.recentVaultGroups == nil { return nil } return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...) } func (u *ui) hiddenVaultRoot() string { if u.state.Section == appstate.SectionEntries { return "Root" } return "" } func (u *ui) enterHiddenVaultRoot() { root := u.hiddenVaultRoot() if root == "" { return } u.setCurrentPath([]string{root}) } func (u *ui) restoreRecentVaultGroup(path string) { saved := u.recentVaultGroup(path) if len(saved) == 0 { u.enterHiddenVaultRoot() return } model, err := u.state.Session.Current() if err != nil { u.enterHiddenVaultRoot() return } root := u.hiddenVaultRoot() if len(saved) == 1 && root != "" && saved[0] == root { u.setCurrentPath(saved) return } if pathExistsInModel(model, saved) { u.setCurrentPath(saved) return } u.enterHiddenVaultRoot() } func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { saved := u.recentRemoteGroup(baseURL, path) if len(saved) == 0 { u.enterHiddenVaultRoot() return } model, err := u.state.Session.Current() if err != nil { u.enterHiddenVaultRoot() return } root := u.hiddenVaultRoot() if len(saved) == 1 && root != "" && saved[0] == root { u.setCurrentPath(saved) return } if pathExistsInModel(model, saved) { u.setCurrentPath(saved) return } u.enterHiddenVaultRoot() } func (u *ui) restoreEntriesPath(path []string) { if len(path) == 0 { u.enterHiddenVaultRoot() return } model, err := u.state.Session.Current() if err != nil { u.enterHiddenVaultRoot() return } root := u.hiddenVaultRoot() if len(path) == 1 && root != "" && path[0] == root { u.setCurrentPath(path) return } if pathExistsInModel(model, path) { u.setCurrentPath(path) return } u.enterHiddenVaultRoot() } func (u *ui) rememberEntriesSectionState() { if u.state.Section != appstate.SectionEntries { return } u.entriesState = entriesSectionState{ Path: append([]string(nil), u.currentPath...), SearchQuery: u.search.Text(), SelectedEntryID: u.state.SelectedEntryID, Editing: u.editingEntry, } } func (u *ui) restoreEntriesSectionState() { u.search.SetText(u.entriesState.SearchQuery) u.restoreEntriesPath(u.entriesState.Path) u.state.SelectedEntryID = u.entriesState.SelectedEntryID u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != "" if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" { u.loadSelectedEntryIntoEditor() } } func (u *ui) displayPath() []string { path := append([]string(nil), u.currentPath...) root := u.hiddenVaultRoot() if root == "" || len(path) == 0 || path[0] != root { return path } return append([]string(nil), path[1:]...) } func (u *ui) displayEntryPath(path []string) []string { root := u.hiddenVaultRoot() if root == "" || len(path) == 0 || path[0] != root { return append([]string(nil), path...) } return append([]string(nil), path[1:]...) } func (u *ui) currentGroupDisplayName() string { displayPath := u.displayPath() if len(displayPath) == 0 { return "Vault root (/)" } return strings.Join(displayPath, " / ") } func (u *ui) parentGroupDisplayName() string { displayPath := u.displayPath() if len(displayPath) <= 1 { return "Vault root (/)" } return strings.Join(displayPath[:len(displayPath)-1], " / ") } func (u *ui) createGroupLabel() string { if len(u.displayPath()) == 0 { return "Create Top-Level Group" } return "Create Subgroup" } func pathHasPrefix(path, prefix []string) bool { if len(prefix) > len(path) { return false } return slices.Equal(path[:len(prefix)], prefix) } func entriesViewPathForModel(model vault.Model, path []string) []string { if len(path) == 0 { return nil } switch { case usesPhysicalEntriesRoot(model) && path[0] == "Root": return append([]string(nil), path[1:]...) case usesLogicalEntriesRoot(model): return append([]string(nil), path...) case path[0] == "Root": return append([]string(nil), path[1:]...) default: return append([]string(nil), path...) } } func hasExactGroup(model vault.Model, path []string) bool { for _, group := range model.Groups { if slices.Equal(group, path) { return true } } return false } func (u *ui) currentGroupDeletionState() (bool, string) { u.syncCurrentPath() if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil { return false, "" } model, err := u.state.Session.Current() if err != nil { return false, "" } view := vaultview.VaultRoot(model) path := entriesViewPathForModel(model, u.currentPath) physicalPath := view.ToPhysicalPath(path) if len(model.ChildGroups(physicalPath)) > 0 { return false, "This group contains child groups. Move or delete them before removing the group." } for _, item := range model.Entries { if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) { return false, "This group contains entries. Move or delete them before removing the group." } } for _, item := range model.Templates { if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { return false, "This group contains templates. Move or delete them before removing the group." } } return true, "Deleting this empty group will not remove any entries." } func usesPhysicalEntriesRoot(model vault.Model) bool { if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 { return true } for _, group := range model.Groups { if len(group) > 0 && group[0] == vaultview.KeepassRoot { return true } } for _, entry := range model.Entries { if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot { return true } } for _, entry := range model.RecycleBin { if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot { return true } } return false } func usesLogicalEntriesRoot(model vault.Model) bool { for _, group := range model.Groups { if len(group) > 0 && group[0] == "Root" { return true } } for _, entry := range model.Entries { if len(entry.Path) > 0 && entry.Path[0] == "Root" { return true } } for _, entry := range model.RecycleBin { if len(entry.Path) > 0 && entry.Path[0] == "Root" { return true } } return false } func (u *ui) deleteGroupPendingConfirmation() bool { return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) } func (u *ui) clearDeleteGroupConfirmation() { u.deleteGroupPath = nil } func (u *ui) armDeleteCurrentGroupAction() { if deletable, _ := u.currentGroupDeletionState(); !deletable { return } u.syncCurrentPath() u.deleteGroupPath = append([]string(nil), u.currentPath...) u.state.ErrorMessage = "" u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))) } func (u *ui) runAction(label string, action func() error) { if strings.TrimSpace(u.loadingMessage) != "" { return } u.loadingMessage = actionLoadingLabel(label) u.loadingActionLabel = strings.TrimSpace(label) if err := action(); err != nil { u.loadingMessage = "" u.loadingActionLabel = "" u.state.ErrorMessage = u.describeActionError(label, err) u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.loadingMessage = "" u.loadingActionLabel = "" u.syncAutofillCache() u.state.ErrorMessage = "" if suppressStatusMessage(label) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.showStatusMessage(label + " complete") } func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { if strings.TrimSpace(u.loadingMessage) != "" { return } u.backgroundActionSerial++ actionID := u.backgroundActionSerial u.activeBackgroundAction = actionID u.loadingMessage = actionLoadingLabel(label) u.loadingActionLabel = strings.TrimSpace(label) u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} go func() { apply, err := prepare() u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID} if u.invalidate != nil { u.invalidate() } }() } func (u *ui) applyBackgroundResult(result backgroundActionResult) { if result.id != 0 && result.id != u.activeBackgroundAction { return } u.activeBackgroundAction = 0 u.loadingMessage = "" u.loadingActionLabel = "" if result.err != nil { u.state.ErrorMessage = u.describeActionError(result.label, result.err) if strings.HasPrefix(result.label, "open ") { u.requestMasterPassFocus = true } u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } if result.apply != nil { if err := result.apply(); err != nil { u.state.ErrorMessage = u.describeActionError(result.label, err) if strings.HasPrefix(result.label, "open ") { u.requestMasterPassFocus = true } u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } } u.syncAutofillCache() u.state.ErrorMessage = "" if suppressStatusMessage(result.label) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.showStatusMessage(result.label + " complete") } func (u *ui) cancelLifecycleBusyState() { if !u.lifecycleBusy() { return } u.activeBackgroundAction = 0 u.loadingMessage = "" u.loadingActionLabel = "" u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} u.requestMasterPassFocus = true } func (u *ui) retryLastLifecycleOpen() { switch strings.TrimSpace(u.lastLifecycleAction) { case "open vault": u.startOpenVaultAction() case "open remote vault": u.startOpenRemoteAction() } } func (u *ui) canRetryLifecycleOpen() bool { if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" { return false } switch strings.TrimSpace(u.lastLifecycleAction) { case "open vault", "open remote vault": return true default: return false } } func (u *ui) processBackgroundActions() { for { select { case result := <-u.backgroundResults: u.applyBackgroundResult(result) default: return } } } func (u *ui) syncAutofillCache() { if strings.TrimSpace(u.autofillCachePath) == "" { return } model, err := u.state.Session.Current() if err != nil { _ = autofillcache.Clear(u.autofillCachePath) return } _ = autofillcache.Write(u.autofillCachePath, model, u.now()) } func suppressStatusMessage(label string) bool { switch strings.TrimSpace(label) { case "open vault", "open remote vault": return true default: return false } } func actionLoadingLabel(label string) string { label = strings.TrimSpace(label) if label == "" { return "Working..." } runes := []rune(label) runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] return string(runes) + "..." } func (u *ui) describeActionError(label string, err error) string { if err == nil { return "" } if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { return "Save conflict: the remote vault changed. Reopen it and retry the save." } if label == "open remote vault" { return fmt.Sprintf("%s failed: %v", label, err) } return err.Error() } func (u *ui) remoteOpenRetryAvailable() bool { return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:") } func (u *ui) selectedRemoteUsesLocalCache() bool { return u.hasSelectedRemoteTarget() && strings.TrimSpace(u.vaultPath.Text()) != "" && strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && 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 "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 { switch { case u.lifecycleBusy(): if u.selectedRemoteUsesLocalCache() { return "Opening Cached Vault..." } return "Creating Local Cache..." case u.remoteOpenRetryAvailable(): if u.selectedRemoteUsesLocalCache() { return "Retry Cached Vault" } return "Retry Local Cache Setup" default: if u.selectedRemoteUsesLocalCache() { return "Open Cached Vault" } return "Create Local Cache" } } 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." }