Add autofill privacy settings controls

This commit is contained in:
Joe Julian
2026-04-01 17:58:37 -07:00
parent 07c277f425
commit 2e03db85a1
3 changed files with 368 additions and 2 deletions
+99 -2
View File
@@ -127,6 +127,7 @@ type statePaths struct {
DefaultSaveAsPath string DefaultSaveAsPath string
RecentVaultsPath string RecentVaultsPath string
RecentRemotesPath string RecentRemotesPath string
SettingsPath string
UIPreferencesPath string UIPreferencesPath string
AutofillCachePath string AutofillCachePath string
} }
@@ -150,6 +151,8 @@ type uiPreferences struct {
GroupControlsHidden bool `json:"groupControlsHidden"` GroupControlsHidden bool `json:"groupControlsHidden"`
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
HistoryHidden bool `json:"historyHidden"` HistoryHidden bool `json:"historyHidden"`
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
DenseLayout bool `json:"denseLayout"` DenseLayout bool `json:"denseLayout"`
StatusBannerMillis int `json:"statusBannerMillis,omitempty"` StatusBannerMillis int `json:"statusBannerMillis,omitempty"`
AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"`
@@ -271,6 +274,10 @@ type ui struct {
settingsReducedMotionOn widget.Clickable settingsReducedMotionOn widget.Clickable
settingsKeyboardFocusStandard widget.Clickable settingsKeyboardFocusStandard widget.Clickable
settingsKeyboardFocusProminent widget.Clickable settingsKeyboardFocusProminent widget.Clickable
showSettingsSyncLocal widget.Clickable
showSettingsSyncRemote widget.Clickable
showSettingsSyncPull widget.Clickable
showSettingsSyncPush widget.Clickable
editEntry widget.Clickable editEntry widget.Clickable
cancelEdit widget.Clickable cancelEdit widget.Clickable
pickVaultPath widget.Clickable pickVaultPath widget.Clickable
@@ -402,10 +409,13 @@ type ui struct {
keyboardFocus focusID keyboardFocus focusID
defaultSaveAsPath string defaultSaveAsPath string
recentVaultsPath string recentVaultsPath string
settingsPath string
uiPreferencesPath string uiPreferencesPath string
recentRemotesPath string recentRemotesPath string
autofillCachePath string autofillCachePath string
editingEntry bool editingEntry bool
syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection
groupControlsHidden bool groupControlsHidden bool
lifecycleAdvancedHidden bool lifecycleAdvancedHidden bool
historyHidden bool historyHidden bool
@@ -547,6 +557,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
lifecycleMode: "local", lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath, defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath, recentVaultsPath: paths.RecentVaultsPath,
settingsPath: paths.SettingsPath,
uiPreferencesPath: paths.UIPreferencesPath, uiPreferencesPath: paths.UIPreferencesPath,
recentRemotesPath: paths.RecentRemotesPath, recentRemotesPath: paths.RecentRemotesPath,
autofillCachePath: paths.AutofillCachePath, autofillCachePath: paths.AutofillCachePath,
@@ -560,6 +571,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
now: time.Now, now: time.Now,
syncSourceMode: syncSourceLocal, syncSourceMode: syncSourceLocal,
syncDirection: syncDirectionPull, syncDirection: syncDirectionPull,
syncDefaultSourceMode: syncSourceLocal,
syncDefaultDirection: syncDirectionPull,
apiPolicyGroupScope: true, apiPolicyGroupScope: true,
autofillNoticePreference: autofillNoticeAll, autofillNoticePreference: autofillNoticeAll,
backgroundResults: make(chan backgroundActionResult, 8), backgroundResults: make(chan backgroundActionResult, 8),
@@ -585,6 +598,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.loadRecentRemotes() u.loadRecentRemotes()
u.restoreStartupLifecycleTarget() u.restoreStartupLifecycleTarget()
u.loadUIPreferences() u.loadUIPreferences()
u.loadSettings()
u.loadSettingsFormFromPreferences() u.loadSettingsFormFromPreferences()
u.loadSettingsDraft() u.loadSettingsDraft()
u.filter() u.filter()
@@ -621,6 +635,7 @@ func defaultStatePaths(stateDir string) statePaths {
DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"),
SettingsPath: filepath.Join(baseDir, "settings.json"),
UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"),
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
} }
@@ -1093,11 +1108,31 @@ func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true u.syncDialogOpen = true
u.syncMenuOpen = false u.syncMenuOpen = false
u.showSyncPassword = false u.showSyncPassword = false
u.syncSourceMode = u.syncDefaultSourceMode
u.syncDirection = u.syncDefaultDirection
if strings.TrimSpace(u.syncLocalPath.Text()) == "" { if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.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 { func (u *ui) advancedSyncAction() error {
switch u.syncDirection { switch u.syncDirection {
case syncDirectionPush: case syncDirectionPush:
@@ -2728,6 +2763,18 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.showSyncPush.Clicked(gtx) { for u.showSyncPush.Clicked(gtx) {
u.syncDirection = syncDirectionPush 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) { for u.showAutofillApprovalAsk.Clicked(gtx) {
u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk 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(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 lbl.Color = mutedColor
return lbl.Layout(gtx) 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(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(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(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(syncDialogSectionLabel(u.theme, "Feedback")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
+190
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"image" "image"
"io" "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) { func TestUIAccessibilityPreferencesPersist(t *testing.T) {
t.Parallel() 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) { func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
t.Parallel() t.Parallel()
+79
View File
@@ -1,7 +1,10 @@
package main package main
import ( import (
"encoding/json"
"image/color" "image/color"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -30,8 +33,23 @@ type accessibilityPreferences struct {
KeyboardFocus string 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 { type settingsDraft struct {
Accessibility accessibilityPreferences Accessibility accessibilityPreferences
Sync syncSettingsDraft
} }
type choiceSpec struct { type choiceSpec struct {
@@ -87,6 +105,10 @@ func (u *ui) loadSettingsDraft() {
ReducedMotion: u.accessibilityPrefs.ReducedMotion, ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, 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.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
} }
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense 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.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility) u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences() u.saveUIPreferences()
u.securityDialogOpen = false u.securityDialogOpen = false
return nil 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) { func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion { if u.accessibilityPrefs.ReducedMotion {