Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 096e5bb48d | |||
| 0b1d560ef9 | |||
| 021cf82758 | |||
| 5e1ca9981d | |||
| 5af8b8174d | |||
| f3ee8794c6 | |||
| 6b224cfde3 | |||
| 6dab14b3d9 |
+456
-7
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
@@ -865,6 +866,48 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUISaveSettingsPersistsUIPreferences(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "ui-prefs.json")
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
u.settingsGroupControls.Value = true
|
||||||
|
u.settingsLifecycleAdvanced.Value = false
|
||||||
|
u.settingsHistory.Value = false
|
||||||
|
u.settingsDenseLayout.Value = true
|
||||||
|
|
||||||
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
||||||
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !reloaded.groupControlsHidden {
|
||||||
|
t.Fatal("groupControlsHidden after reload = false, want true")
|
||||||
|
}
|
||||||
|
if reloaded.lifecycleAdvancedHidden {
|
||||||
|
t.Fatal("lifecycleAdvancedHidden after reload = true, want false")
|
||||||
|
}
|
||||||
|
if reloaded.historyHidden {
|
||||||
|
t.Fatal("historyHidden after reload = true, want false")
|
||||||
|
}
|
||||||
|
if !reloaded.denseLayout {
|
||||||
|
t.Fatal("denseLayout after reload = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) {
|
func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2629,9 +2672,9 @@ func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) {
|
|||||||
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
|
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true)
|
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), true)
|
||||||
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true)
|
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, defaultAccessibilityPreferences(), true)
|
||||||
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false)
|
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), false)
|
||||||
|
|
||||||
if got := lo.MinHeight; got != 44 {
|
if got := lo.MinHeight; got != 44 {
|
||||||
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got)
|
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got)
|
||||||
@@ -2872,6 +2915,47 @@ func TestUIStatusToastExpiresAfterTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIStatusToastExpiresAfterConfiguredTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.now = func() time.Time { return now }
|
||||||
|
u.statusBannerTTL = statusBannerLong
|
||||||
|
u.state.StatusMessage = "save complete"
|
||||||
|
u.statusExpiresAt = now.Add(u.statusBannerTTL)
|
||||||
|
|
||||||
|
now = now.Add(statusBannerDuration + time.Second)
|
||||||
|
if got := u.statusToastSurface(); got.Kind != bannerStatus {
|
||||||
|
t.Fatalf("statusToastSurface() before configured expiry = %#v, want visible status toast", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
now = now.Add(statusBannerLong)
|
||||||
|
if got := u.statusToastSurface(); got.Kind != bannerNone {
|
||||||
|
t.Fatalf("statusToastSurface() after configured expiry = %#v, want no toast", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIReducedMotionKeepsStatusToastVisible(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.now = func() time.Time { return now }
|
||||||
|
u.statusBannerTTL = statusBannerLong
|
||||||
|
u.applyAccessibilityPreferences(accessibilityPreferences{ReducedMotion: true})
|
||||||
|
|
||||||
|
u.showStatusMessage("synchronize vault complete")
|
||||||
|
if !u.statusExpiresAt.IsZero() {
|
||||||
|
t.Fatalf("statusExpiresAt with reduced motion = %v, want zero", u.statusExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
now = now.Add(statusBannerLong * 2)
|
||||||
|
if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
|
||||||
|
t.Fatalf("statusToastSurface() with reduced motion = %#v, want persistent status toast", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
|
func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2903,6 +2987,41 @@ func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIAutofillStatusSurfaceRespectsNoticePreference(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.now = func() time.Time { return now }
|
||||||
|
u.auditLog = &apiaudit.Log{}
|
||||||
|
u.auditLog.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillFound,
|
||||||
|
TokenName: "Browser Extension",
|
||||||
|
ClientName: "Firefox",
|
||||||
|
Operation: apitokens.OperationCopyPassword,
|
||||||
|
At: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
u.autofillNoticePreference = autofillNoticeApprovals
|
||||||
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
||||||
|
t.Fatalf("autofillStatusSurface() with approvals-only preference = %#v, want no recent notice", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.autofillNoticePreference = autofillNoticeSuppressed
|
||||||
|
u.state.Approvals = &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "Browser Extension",
|
||||||
|
ClientName: "Firefox",
|
||||||
|
Operation: apitokens.OperationCopyPassword,
|
||||||
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
||||||
|
t.Fatalf("autofillStatusSurface() with suppressed preference = %#v, want no notice", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) {
|
func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -3405,13 +3524,15 @@ func TestUIGroupToolsDisclosureStatePersists(t *testing.T) {
|
|||||||
|
|
||||||
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
||||||
|
|
||||||
first := newUIWithSession("desktop", &session.Manager{})
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
first.uiPreferencesPath = configPath
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
first.groupControlsHidden = true
|
first.groupControlsHidden = true
|
||||||
first.saveUIPreferences()
|
first.saveUIPreferences()
|
||||||
|
|
||||||
second := newUIWithSession("desktop", &session.Manager{})
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
second.uiPreferencesPath = configPath
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
second.groupControlsHidden = false
|
second.groupControlsHidden = false
|
||||||
second.loadUIPreferences()
|
second.loadUIPreferences()
|
||||||
|
|
||||||
@@ -3420,6 +3541,327 @@ func TestUIGroupToolsDisclosureStatePersists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIDenseLayoutPreferencePersists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
||||||
|
|
||||||
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
first.denseLayout = true
|
||||||
|
first.saveUIPreferences()
|
||||||
|
|
||||||
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
second.denseLayout = false
|
||||||
|
second.loadUIPreferences()
|
||||||
|
|
||||||
|
if !second.denseLayout {
|
||||||
|
t.Fatal("denseLayout = false after reload, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUISyncDefaultsPersistInSettings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
|
||||||
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
SettingsPath: configPath,
|
||||||
|
})
|
||||||
|
first.syncDefaultSourceMode = syncSourceRemote
|
||||||
|
first.syncDefaultDirection = syncDirectionPush
|
||||||
|
first.saveSettings()
|
||||||
|
|
||||||
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
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(legacySyncPreferences{
|
||||||
|
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{}, statePaths{
|
||||||
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
||||||
|
UIPreferencesPath: legacyPath,
|
||||||
|
})
|
||||||
|
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{}
|
||||||
|
dir := t.TempDir()
|
||||||
|
u := newUIWithSession("desktop", manager, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
||||||
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
||||||
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
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()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
||||||
|
|
||||||
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
first.applyAccessibilityPreferences(accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityComfortable,
|
||||||
|
Contrast: contrastHigh,
|
||||||
|
ReducedMotion: true,
|
||||||
|
KeyboardFocus: keyboardFocusProminent,
|
||||||
|
})
|
||||||
|
first.saveUIPreferences()
|
||||||
|
|
||||||
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
second.loadUIPreferences()
|
||||||
|
|
||||||
|
if second.denseLayout {
|
||||||
|
t.Fatal("denseLayout after reload = true, want comfortable layout preference")
|
||||||
|
}
|
||||||
|
if got := second.accessibilityPrefs; got != (accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityComfortable,
|
||||||
|
Contrast: contrastHigh,
|
||||||
|
ReducedMotion: true,
|
||||||
|
KeyboardFocus: keyboardFocusProminent,
|
||||||
|
}) {
|
||||||
|
t.Fatalf("accessibilityPrefs after reload = %#v, want comfortable/high/reduced/prominent", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldFocusAppearanceUsesAccessibilityPreferences(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
metric := unit.Metric{PxPerDp: 1, PxPerSp: 1}
|
||||||
|
base := fieldFocusAppearance(metric, defaultAccessibilityPreferences(), true)
|
||||||
|
comfortable := fieldFocusAppearance(metric, accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityComfortable,
|
||||||
|
Contrast: contrastHigh,
|
||||||
|
KeyboardFocus: keyboardFocusProminent,
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
if comfortable.MinHeight <= base.MinHeight {
|
||||||
|
t.Fatalf("fieldFocusAppearance(comfortable).MinHeight = %d, want > %d", comfortable.MinHeight, base.MinHeight)
|
||||||
|
}
|
||||||
|
if comfortable.OutlineWidth <= base.OutlineWidth {
|
||||||
|
t.Fatalf("fieldFocusAppearance(prominent).OutlineWidth = %d, want > %d", comfortable.OutlineWidth, base.OutlineWidth)
|
||||||
|
}
|
||||||
|
if comfortable.OutlineColor.A <= base.OutlineColor.A {
|
||||||
|
t.Fatalf("fieldFocusAppearance(high contrast).OutlineColor.A = %d, want > %d", comfortable.OutlineColor.A, base.OutlineColor.A)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
|
||||||
|
comfortableInset, comfortableTitle, _, _, _, comfortableGap := u.entryRowMetrics()
|
||||||
|
u.denseLayout = true
|
||||||
|
denseInset, denseTitle, _, _, _, denseGap := u.entryRowMetrics()
|
||||||
|
|
||||||
|
if denseInset >= comfortableInset {
|
||||||
|
t.Fatalf("dense inset = %v, want smaller than comfortable inset %v", denseInset, comfortableInset)
|
||||||
|
}
|
||||||
|
if denseTitle >= comfortableTitle {
|
||||||
|
t.Fatalf("dense title size = %v, want smaller than comfortable title size %v", denseTitle, comfortableTitle)
|
||||||
|
}
|
||||||
|
if denseGap >= comfortableGap {
|
||||||
|
t.Fatalf("dense divider gap = %v, want smaller than comfortable divider gap %v", denseGap, comfortableGap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUINotificationPreferencesPersist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
||||||
|
|
||||||
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
first.statusBannerTTL = statusBannerLong
|
||||||
|
first.autofillNoticePreference = autofillNoticeApprovals
|
||||||
|
first.saveUIPreferences()
|
||||||
|
|
||||||
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
UIPreferencesPath: configPath,
|
||||||
|
})
|
||||||
|
second.statusBannerTTL = statusBannerDuration
|
||||||
|
second.autofillNoticePreference = autofillNoticeAll
|
||||||
|
second.loadUIPreferences()
|
||||||
|
|
||||||
|
if got := second.statusBannerTTL; got != statusBannerLong {
|
||||||
|
t.Fatalf("statusBannerTTL after reload = %v, want %v", got, statusBannerLong)
|
||||||
|
}
|
||||||
|
if got := second.autofillNoticePreference; got != autofillNoticeApprovals {
|
||||||
|
t.Fatalf("autofillNoticePreference after reload = %q, want %q", got, autofillNoticeApprovals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -3757,6 +4199,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
|||||||
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
|
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
|
||||||
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
|
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
|
||||||
}
|
}
|
||||||
|
if got := paths.SettingsPath; got != filepath.Join(base, "settings.json") {
|
||||||
|
t.Fatalf("SettingsPath = %q, want %q", got, filepath.Join(base, "settings.json"))
|
||||||
|
}
|
||||||
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
|
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
|
||||||
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
|
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
|
||||||
}
|
}
|
||||||
@@ -3780,6 +4225,9 @@ func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
|
|||||||
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
|
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
|
||||||
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
|
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
|
||||||
}
|
}
|
||||||
|
if got := paths.SettingsPath; got != filepath.Join(base, "settings.json") {
|
||||||
|
t.Fatalf("SettingsPath = %q, want %q", got, filepath.Join(base, "settings.json"))
|
||||||
|
}
|
||||||
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
|
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
|
||||||
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
|
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
|
||||||
}
|
}
|
||||||
@@ -3797,6 +4245,7 @@ func TestRunActionSynchronizesAutofillCache(t *testing.T) {
|
|||||||
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
||||||
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
AutofillCachePath: cachePath,
|
AutofillCachePath: cachePath,
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-2
@@ -19,26 +19,49 @@ type focusAppearance struct {
|
|||||||
MinHeight int
|
MinHeight int
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
|
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
appearance := focusAppearance{
|
appearance := focusAppearance{
|
||||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||||
OutlineColor: color.NRGBA{A: 0},
|
OutlineColor: color.NRGBA{A: 0},
|
||||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||||
MinHeight: metric.Dp(unit.Dp(44)),
|
MinHeight: metric.Dp(unit.Dp(44)),
|
||||||
}
|
}
|
||||||
|
if prefs.DisplayDensity == displayDensityComfortable {
|
||||||
|
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||||
|
}
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||||
|
}
|
||||||
if focused {
|
if focused {
|
||||||
appearance.BorderColor = accentColor
|
appearance.BorderColor = accentColor
|
||||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||||
|
}
|
||||||
|
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return appearance
|
return appearance
|
||||||
}
|
}
|
||||||
|
|
||||||
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
|
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||||
text = accentColor
|
text = accentColor
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||||
|
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||||
|
}
|
||||||
if focused {
|
if focused {
|
||||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||||
|
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return background, text
|
return background, text
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-16
@@ -969,15 +969,15 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions {
|
return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -985,9 +985,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions {
|
return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText())
|
lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText())
|
||||||
@@ -1057,7 +1057,7 @@ func (u *ui) entryEditorPanel(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 {
|
||||||
return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions {
|
return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions {
|
||||||
return labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx)
|
return labeledMultilineEditorWithFocus(u.theme, u.accessibilityPrefs, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
@@ -1065,7 +1065,7 @@ func (u *ui) entryEditorPanel(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 {
|
||||||
return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions {
|
return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions {
|
||||||
return labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx)
|
return labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
@@ -1142,17 +1142,17 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
|
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
|
||||||
return labeledEditorWithFocus(th, label, editor, sensitive, false)
|
return labeledEditorWithFocus(th, defaultAccessibilityPreferences(), label, editor, sensitive, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
|
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
|
||||||
return labeledEditorHelpFocus(th, label, help, editor, sensitive, false)
|
return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
|
func labeledEditorHelpFocus(th *material.Theme, prefs accessibilityPreferences, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
|
||||||
return func(gtx layout.Context) layout.Dimensions {
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(labeledEditorWithFocus(th, label, editor, sensitive, focused)),
|
layout.Rigid(labeledEditorWithFocus(th, prefs, label, editor, sensitive, focused)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(th, unit.Sp(11), help)
|
lbl := material.Label(th, unit.Sp(11), help)
|
||||||
@@ -1276,7 +1276,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi
|
|||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
|
return u.outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -1313,6 +1313,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi
|
|||||||
|
|
||||||
func labeledEditorWithFocus(
|
func labeledEditorWithFocus(
|
||||||
th *material.Theme,
|
th *material.Theme,
|
||||||
|
prefs accessibilityPreferences,
|
||||||
label string,
|
label string,
|
||||||
editor *widget.Editor,
|
editor *widget.Editor,
|
||||||
sensitive bool,
|
sensitive bool,
|
||||||
@@ -1326,7 +1327,7 @@ func labeledEditorWithFocus(
|
|||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
|
return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions {
|
||||||
mask := editor.Mask
|
mask := editor.Mask
|
||||||
if sensitive {
|
if sensitive {
|
||||||
editor.Mask = '•'
|
editor.Mask = '•'
|
||||||
@@ -1343,6 +1344,7 @@ func labeledEditorWithFocus(
|
|||||||
|
|
||||||
func labeledMultilineEditorWithFocus(
|
func labeledMultilineEditorWithFocus(
|
||||||
th *material.Theme,
|
th *material.Theme,
|
||||||
|
prefs accessibilityPreferences,
|
||||||
label string,
|
label string,
|
||||||
editor *widget.Editor,
|
editor *widget.Editor,
|
||||||
sensitive bool,
|
sensitive bool,
|
||||||
@@ -1357,7 +1359,7 @@ func labeledMultilineEditorWithFocus(
|
|||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
|
return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions {
|
||||||
mask := editor.Mask
|
mask := editor.Mask
|
||||||
if sensitive {
|
if sensitive {
|
||||||
editor.Mask = '•'
|
editor.Mask = '•'
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"image/color"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
displayDensityDense = "dense"
|
||||||
|
displayDensityComfortable = "comfortable"
|
||||||
|
|
||||||
|
contrastStandard = "standard"
|
||||||
|
contrastHigh = "high"
|
||||||
|
|
||||||
|
keyboardFocusStandard = "standard"
|
||||||
|
keyboardFocusProminent = "prominent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accessibilityPreferences struct {
|
||||||
|
DisplayDensity string
|
||||||
|
Contrast string
|
||||||
|
ReducedMotion bool
|
||||||
|
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 legacySyncPreferences struct {
|
||||||
|
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
|
||||||
|
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type choiceSpec struct {
|
||||||
|
Click *widget.Clickable
|
||||||
|
Label string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||||
|
return accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityForDenseLayout(true),
|
||||||
|
Contrast: contrastStandard,
|
||||||
|
KeyboardFocus: keyboardFocusStandard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayDensityForDenseLayout(dense bool) string {
|
||||||
|
if dense {
|
||||||
|
return displayDensityDense
|
||||||
|
}
|
||||||
|
return displayDensityComfortable
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||||
|
normalized := defaultAccessibilityPreferences()
|
||||||
|
switch prefs.DisplayDensity {
|
||||||
|
case displayDensityDense, displayDensityComfortable:
|
||||||
|
normalized.DisplayDensity = prefs.DisplayDensity
|
||||||
|
}
|
||||||
|
switch prefs.Contrast {
|
||||||
|
case contrastStandard, contrastHigh:
|
||||||
|
normalized.Contrast = prefs.Contrast
|
||||||
|
}
|
||||||
|
switch prefs.KeyboardFocus {
|
||||||
|
case keyboardFocusStandard, keyboardFocusProminent:
|
||||||
|
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||||
|
}
|
||||||
|
normalized.ReducedMotion = prefs.ReducedMotion
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||||
|
normalized := normalizeAccessibilityPreferences(prefs)
|
||||||
|
u.denseLayout = normalized.DisplayDensity == displayDensityDense
|
||||||
|
u.accessibilityPrefs = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) loadSettingsDraft() {
|
||||||
|
u.settingsDraft = settingsDraft{
|
||||||
|
Accessibility: accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
|
||||||
|
Contrast: u.accessibilityPrefs.Contrast,
|
||||||
|
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
|
||||||
|
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
|
||||||
|
},
|
||||||
|
Sync: syncSettingsDraft{
|
||||||
|
SourceDefault: u.syncDefaultSourceMode,
|
||||||
|
DirectionDefault: u.syncDefaultDirection,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveSecuritySettingsAction() error {
|
||||||
|
settings := vault.SecuritySettings{
|
||||||
|
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||||
|
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||||
|
}
|
||||||
|
if err := u.state.ConfigureSecurity(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||||
|
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 legacySyncPreferences
|
||||||
|
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 {
|
||||||
|
u.statusExpiresAt = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
|
||||||
|
return sectionCard(gtx, u.theme, title, detail, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(th, unit.Sp(12), title)
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(th, unit.Sp(13), body)
|
||||||
|
lbl.Color = th.Palette.Fg
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
|
||||||
|
children := make([]layout.FlexChild, 0, len(choices)*2)
|
||||||
|
for i, choice := range choices {
|
||||||
|
if i > 0 {
|
||||||
|
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
|
||||||
|
}
|
||||||
|
current := choice
|
||||||
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listRowColors struct {
|
||||||
|
Title color.NRGBA
|
||||||
|
Meta color.NRGBA
|
||||||
|
Secondary color.NRGBA
|
||||||
|
Divider color.NRGBA
|
||||||
|
Fill color.NRGBA
|
||||||
|
Edge color.NRGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
|
||||||
|
colors := listRowColors{
|
||||||
|
Title: accentColor,
|
||||||
|
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
|
||||||
|
Secondary: mutedColor,
|
||||||
|
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
|
||||||
|
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
|
||||||
|
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||||
|
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
|
||||||
|
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
|
||||||
|
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
|
||||||
|
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
|
||||||
|
}
|
||||||
|
if recycleBin {
|
||||||
|
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
|
||||||
|
}
|
||||||
|
if focused && !selected {
|
||||||
|
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
|
||||||
|
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
|
||||||
|
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
|
||||||
|
}
|
||||||
|
if u.accessibilityPrefs.Contrast == contrastHigh {
|
||||||
|
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
|
||||||
|
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
|
||||||
|
if focused || selected {
|
||||||
|
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
|
||||||
|
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
|
||||||
|
}
|
||||||
|
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
|
||||||
|
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
|
||||||
|
}
|
||||||
|
return colors
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user