Files
keepassgo/internal/appui/recent_state.go
T
2026-04-11 11:26:00 -07:00

1782 lines
53 KiB
Go

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 ""
}
model, err := u.state.Session.Current()
if err != nil {
return ""
}
return vaultview.HiddenRoot(model)
}
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 len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(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 len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(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 len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(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 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, ""
}
path := append([]string(nil), u.currentPath...)
if len(model.ChildGroups(path)) > 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, path) || pathHasPrefix(item.Path, path) {
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 (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."
}