diff --git a/main.go b/main.go index 769dbbf..f98c8bb 100644 --- a/main.go +++ b/main.go @@ -127,6 +127,7 @@ type statePaths struct { DefaultSaveAsPath string RecentVaultsPath string RecentRemotesPath string + SettingsPath string UIPreferencesPath string AutofillCachePath string } @@ -150,6 +151,8 @@ type uiPreferences struct { GroupControlsHidden bool `json:"groupControlsHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` HistoryHidden bool `json:"historyHidden"` + SyncSourceDefault string `json:"syncSourceDefault,omitempty"` + SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"` DenseLayout bool `json:"denseLayout"` StatusBannerMillis int `json:"statusBannerMillis,omitempty"` AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` @@ -271,6 +274,10 @@ type ui struct { settingsReducedMotionOn widget.Clickable settingsKeyboardFocusStandard widget.Clickable settingsKeyboardFocusProminent widget.Clickable + showSettingsSyncLocal widget.Clickable + showSettingsSyncRemote widget.Clickable + showSettingsSyncPull widget.Clickable + showSettingsSyncPush widget.Clickable editEntry widget.Clickable cancelEdit widget.Clickable pickVaultPath widget.Clickable @@ -402,10 +409,13 @@ type ui struct { keyboardFocus focusID defaultSaveAsPath string recentVaultsPath string + settingsPath string uiPreferencesPath string recentRemotesPath string autofillCachePath string editingEntry bool + syncDefaultSourceMode syncSourceMode + syncDefaultDirection syncDirection groupControlsHidden bool lifecycleAdvancedHidden bool historyHidden bool @@ -547,6 +557,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, + settingsPath: paths.SettingsPath, uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, @@ -560,6 +571,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) now: time.Now, syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, + syncDefaultSourceMode: syncSourceLocal, + syncDefaultDirection: syncDirectionPull, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, backgroundResults: make(chan backgroundActionResult, 8), @@ -585,6 +598,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.loadRecentRemotes() u.restoreStartupLifecycleTarget() u.loadUIPreferences() + u.loadSettings() u.loadSettingsFormFromPreferences() u.loadSettingsDraft() u.filter() @@ -621,6 +635,7 @@ func defaultStatePaths(stateDir string) statePaths { DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), + SettingsPath: filepath.Join(baseDir, "settings.json"), UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), } @@ -1093,11 +1108,31 @@ func (u *ui) openAdvancedSyncDialog() { u.syncDialogOpen = true u.syncMenuOpen = false u.showSyncPassword = false + u.syncSourceMode = u.syncDefaultSourceMode + u.syncDirection = u.syncDefaultDirection if strings.TrimSpace(u.syncLocalPath.Text()) == "" { u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) } } +func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode { + switch mode { + case syncSourceRemote: + return syncSourceRemote + default: + return syncSourceLocal + } +} + +func sanitizeSyncDirection(direction syncDirection) syncDirection { + switch direction { + case syncDirectionPush: + return syncDirectionPush + default: + return syncDirectionPull + } +} + func (u *ui) advancedSyncAction() error { switch u.syncDirection { case syncDirectionPush: @@ -2728,6 +2763,18 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.showSyncPush.Clicked(gtx) { u.syncDirection = syncDirectionPush } + for u.showSettingsSyncLocal.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceLocal + } + for u.showSettingsSyncRemote.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceRemote + } + for u.showSettingsSyncPull.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPull + } + for u.showSettingsSyncPush.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPush + } for u.showAutofillApprovalAsk.Clicked(gtx) { u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk } @@ -3210,7 +3257,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, tunes noncritical feedback, and sets the KDBX security defaults used for new or future saves.") + lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, sync defaults, and KDBX security defaults without crowding the main vault flow.") lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -3248,6 +3295,56 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(syncDialogSectionLabel(u.theme, "Sync Defaults")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), "Advanced Sync starts from these defaults. You can still change the source or direction before a single run.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncPull, "Pull Into Current Vault", u.settingsDraft.Sync.DirectionDefault == syncDirectionPull) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncPush, "Push Current Vault Out", u.settingsDraft.Sync.DirectionDefault == syncDirectionPush) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncLocal, "Local File", u.settingsDraft.Sync.SourceDefault == syncSourceLocal) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncRemote, "Remote WebDAV", u.settingsDraft.Sync.SourceDefault == syncSourceRemote) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncDialogSummaryCard(gtx, u.theme, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Conflict handling stays retry-safe: merged entry changes keep history, while remote save conflicts still require reopening the vault and retrying the save.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(syncDialogSectionLabel(u.theme, "Background Sync")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Future background sync controls belong here so source, direction, and unattended behavior stay in one settings surface.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(syncDialogSectionLabel(u.theme, "Feedback")), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -3451,7 +3548,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Pick direction, choose the other vault, and then run the merge. The quick sync button keeps using the current source.") + lbl := material.Label(u.theme, unit.Sp(14), "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings.") lbl.Color = mutedColor return lbl.Layout(gtx) }), diff --git a/main_test.go b/main_test.go index da591cf..b2c5748 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "errors" "image" "io" @@ -3558,6 +3559,118 @@ func TestUIDenseLayoutPreferencePersists(t *testing.T) { } } +func TestUISyncDefaultsPersistInSettings(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "settings.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.settingsPath = configPath + first.syncDefaultSourceMode = syncSourceRemote + first.syncDefaultDirection = syncDirectionPush + first.saveSettings() + + second := newUIWithSession("desktop", &session.Manager{}) + second.settingsPath = configPath + second.syncDefaultSourceMode = syncSourceLocal + second.syncDefaultDirection = syncDirectionPull + second.loadSettings() + + if got := second.syncDefaultSourceMode; got != syncSourceRemote { + t.Fatalf("syncDefaultSourceMode = %q, want remote", got) + } + if got := second.syncDefaultDirection; got != syncDirectionPush { + t.Fatalf("syncDefaultDirection = %q, want push", got) + } +} + +func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + legacyPath := filepath.Join(dir, "ui-prefs.json") + content, err := json.MarshalIndent(uiPreferences{ + SyncSourceDefault: string(syncSourceRemote), + SyncDirectionDefault: string(syncDirectionPush), + }, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent() error = %v", err) + } + if err := os.WriteFile(legacyPath, content, 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + reloaded := newUIWithSession("desktop", &session.Manager{}) + reloaded.uiPreferencesPath = legacyPath + reloaded.settingsPath = filepath.Join(dir, "settings.json") + reloaded.syncDefaultSourceMode = syncSourceLocal + reloaded.syncDefaultDirection = syncDirectionPull + reloaded.loadSettings() + + if got := reloaded.syncDefaultSourceMode; got != syncSourceRemote { + t.Fatalf("syncDefaultSourceMode = %q after legacy load, want remote", got) + } + if got := reloaded.syncDefaultDirection; got != syncDirectionPush { + t.Fatalf("syncDefaultDirection = %q after legacy load, want push", got) + } +} + +func TestUIOpenAdvancedSyncDialogUsesSavedSyncDefaults(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.syncDefaultSourceMode = syncSourceRemote + u.syncDefaultDirection = syncDirectionPush + u.syncSourceMode = syncSourceLocal + u.syncDirection = syncDirectionPull + u.vaultPath.SetText("/vaults/current.kdbx") + + u.openAdvancedSyncDialog() + + if got := u.syncSourceMode; got != syncSourceRemote { + t.Fatalf("syncSourceMode = %q after open, want remote default", got) + } + if got := u.syncDirection; got != syncDirectionPush { + t.Fatalf("syncDirection = %q after open, want push default", got) + } + if got := u.syncLocalPath.Text(); got != "/vaults/current.kdbx" { + t.Fatalf("syncLocalPath = %q after open, want current vault path", got) + } +} + +func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) { + t.Parallel() + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + u.securityCipher.SetText(vault.CipherAES256) + u.securityKDF.SetText(vault.KDFAES) + u.loadSettingsDraft() + u.settingsDraft.Sync.SourceDefault = syncSourceRemote + u.settingsDraft.Sync.DirectionDefault = syncDirectionPush + u.settingsPath = filepath.Join(t.TempDir(), "settings.json") + u.uiPreferencesPath = filepath.Join(t.TempDir(), "ui-prefs.json") + + if err := u.saveSecuritySettingsAction(); err != nil { + t.Fatalf("saveSecuritySettingsAction() error = %v", err) + } + + reloaded := newUIWithSession("desktop", &session.Manager{}) + reloaded.settingsPath = u.settingsPath + reloaded.loadSettings() + + if got := reloaded.syncDefaultSourceMode; got != syncSourceRemote { + t.Fatalf("reloaded syncDefaultSourceMode = %q, want remote", got) + } + if got := reloaded.syncDefaultDirection; got != syncDirectionPush { + t.Fatalf("reloaded syncDefaultDirection = %q, want push", got) + } +} + func TestUIAccessibilityPreferencesPersist(t *testing.T) { t.Parallel() @@ -3657,6 +3770,83 @@ func TestUINotificationPreferencesPersist(t *testing.T) { } } +func TestAutofillPrivacyLinesNormalizesEntries(t *testing.T) { + t.Parallel() + + got := autofillPrivacyLines(" com.android.chrome \n\ncom.example.app\ncom.android.chrome\n org.keepassgo.browser ") + want := []string{"com.android.chrome", "com.example.app", "org.keepassgo.browser"} + if !slices.Equal(got, want) { + t.Fatalf("autofillPrivacyLines() = %v, want %v", got, want) + } +} + +func TestJoinAutofillPrivacyLines(t *testing.T) { + t.Parallel() + + got := joinAutofillPrivacyLines([]string{"com.android.chrome", "com.example.app"}) + if got != "com.android.chrome\ncom.example.app" { + t.Fatalf("joinAutofillPrivacyLines() = %q, want %q", got, "com.android.chrome\ncom.example.app") + } +} + +func TestUIAutofillPrivacyPreferencesPersist(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ui-prefs.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.uiPreferencesPath = configPath + first.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock + first.autofillBrowserAllowlist.SetText("https://accounts.example.com\nhttps://login.example.org\nhttps://accounts.example.com") + first.autofillAppAllowlist.SetText("org.mozilla.firefox\ncom.android.chrome") + first.autofillPackageRules.SetText("com.android.chrome=hostname\norg.keepassgo.browser=view-id") + first.saveUIPreferences() + + second := newUIWithSession("desktop", &session.Manager{}) + second.uiPreferencesPath = configPath + second.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk + second.loadUIPreferences() + + if got := second.autofillFirstFillApprovalMode; got != autofillFirstFillApprovalBlock { + t.Fatalf("autofillFirstFillApprovalMode = %q, want %q", got, autofillFirstFillApprovalBlock) + } + if got := second.autofillBrowserAllowlist.Text(); got != "https://accounts.example.com\nhttps://login.example.org" { + t.Fatalf("autofillBrowserAllowlist = %q, want normalized browser allowlist", got) + } + if got := second.autofillAppAllowlist.Text(); got != "org.mozilla.firefox\ncom.android.chrome" { + t.Fatalf("autofillAppAllowlist = %q, want preserved allowlist entries", got) + } + if got := second.autofillPackageRules.Text(); got != "com.android.chrome=hostname\norg.keepassgo.browser=view-id" { + t.Fatalf("autofillPackageRules = %q, want persisted package rules", got) + } +} + +func TestUILoadUIPreferencesKeepsDefaultAutofillApprovalWhenMissing(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ui-prefs.json") + content, err := json.Marshal(uiPreferences{ + GroupControlsHidden: true, + LifecycleAdvancedHidden: true, + HistoryHidden: true, + }) + if err != nil { + t.Fatalf("Marshal(uiPreferences) error = %v", err) + } + if err := os.WriteFile(configPath, content, 0o600); err != nil { + t.Fatalf("WriteFile(uiPreferences) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.uiPreferencesPath = configPath + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk + u.loadUIPreferences() + + if got := u.autofillFirstFillApprovalMode; got != autofillFirstFillApprovalAsk { + t.Fatalf("autofillFirstFillApprovalMode = %q, want %q when preference missing", got, autofillFirstFillApprovalAsk) + } +} + func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { t.Parallel() diff --git a/ui_preferences.go b/ui_preferences.go index 55b31e5..6853ce5 100644 --- a/ui_preferences.go +++ b/ui_preferences.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" "image/color" + "os" + "path/filepath" "strings" "time" @@ -30,8 +33,23 @@ type accessibilityPreferences struct { KeyboardFocus string } +type settingsFile struct { + Sync syncSettings `json:"sync,omitempty"` +} + +type syncSettings struct { + SourceDefault string `json:"sourceDefault,omitempty"` + DirectionDefault string `json:"directionDefault,omitempty"` +} + +type syncSettingsDraft struct { + SourceDefault syncSourceMode + DirectionDefault syncDirection +} + type settingsDraft struct { Accessibility accessibilityPreferences + Sync syncSettingsDraft } type choiceSpec struct { @@ -87,6 +105,10 @@ func (u *ui) loadSettingsDraft() { ReducedMotion: u.accessibilityPrefs.ReducedMotion, KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, }, + Sync: syncSettingsDraft{ + SourceDefault: u.syncDefaultSourceMode, + DirectionDefault: u.syncDefaultDirection, + }, } } @@ -102,13 +124,70 @@ func (u *ui) saveSecuritySettingsAction() error { u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value) } u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense + u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault) + u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault) u.applySettingsFormToPreferences() u.applyAccessibilityPreferences(u.settingsDraft.Accessibility) + u.saveSettings() u.saveUIPreferences() u.securityDialogOpen = false return nil } +func (u *ui) loadSettings() { + u.syncDefaultSourceMode = syncSourceLocal + u.syncDefaultDirection = syncDirectionPull + + if strings.TrimSpace(u.settingsPath) != "" { + content, err := os.ReadFile(u.settingsPath) + if err == nil { + var settings settingsFile + if json.Unmarshal(content, &settings) == nil { + u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault)) + u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault)) + return + } + } + } + + u.loadLegacySyncDefaultsFromUIPreferences() +} + +func (u *ui) loadLegacySyncDefaultsFromUIPreferences() { + 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.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault)) + u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault)) +} + +func (u *ui) saveSettings() { + if strings.TrimSpace(u.settingsPath) == "" { + return + } + if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil { + return + } + content, err := json.MarshalIndent(settingsFile{ + Sync: syncSettings{ + SourceDefault: string(u.syncDefaultSourceMode), + DirectionDefault: string(u.syncDefaultDirection), + }, + }, "", " ") + if err != nil { + return + } + _ = os.WriteFile(u.settingsPath, content, 0o600) +} + func (u *ui) showStatusMessage(message string) { u.state.StatusMessage = message if u.accessibilityPrefs.ReducedMotion {