package main import ( "encoding/json" "errors" "flag" "fmt" "image" "image/color" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" "time" "gioui.org/app" "gioui.org/gesture" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "git.julianfamily.org/keepassgo/api" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/appstate" keepassassets "git.julianfamily.org/keepassgo/assets" "git.julianfamily.org/keepassgo/autofillcache" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" "golang.org/x/exp/shiny/materialdesign/icons" ) type entry = vault.Entry const ( productName = "KeePassGO" ) const ( maxAttachmentBytes = 10 << 20 statusBannerDuration = 2600 * time.Millisecond statusBannerLong = 6 * time.Second autofillStatusTTL = 12 * time.Second ) type autofillNoticeMode string const ( autofillNoticeAll autofillNoticeMode = "all" autofillNoticeApprovals autofillNoticeMode = "approvals" autofillNoticeSuppressed autofillNoticeMode = "suppressed" ) type bannerKind string const ( bannerNone bannerKind = "" bannerLoading bannerKind = "loading" bannerError bannerKind = "error" bannerStatus bannerKind = "status" ) type uiBanner struct { Kind bannerKind Message string Detail string Dismissable bool } type autofillStatusKind string const ( autofillStatusNone autofillStatusKind = "" autofillStatusFound autofillStatusKind = "found" autofillStatusAmbiguous autofillStatusKind = "ambiguous" autofillStatusBlocked autofillStatusKind = "blocked" autofillStatusAwaitingApproval autofillStatusKind = "awaiting_approval" ) type uiAutofillStatus struct { Kind autofillStatusKind Title string Message string Detail string } type uiSurface struct { Title string Message string Locked bool } type emptyState struct { Title string Body string } type vaultSummary struct { Title string Detail string Context string } type sessionStatus interface { HasVault() bool IsLocked() bool IsRemote() bool } type attachmentItem struct { Name string Size int } type statePaths struct { DefaultSaveAsPath string RecentVaultsPath string RecentRemotesPath string SettingsPath string UIPreferencesPath string AutofillCachePath string } type recentVaultRecord struct { Path string `json:"path"` LastGroup []string `json:"lastGroup,omitempty"` UsedAt string `json:"usedAt,omitempty"` } type recentRemoteRecord struct { BaseURL string `json:"baseUrl"` Path string `json:"path"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` LastGroup []string `json:"lastGroup,omitempty"` UsedAt string `json:"usedAt,omitempty"` } type uiPreferences struct { GroupControlsHidden bool `json:"groupControlsHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` HistoryHidden bool `json:"historyHidden"` DenseLayout bool `json:"denseLayout"` StatusBannerMillis int `json:"statusBannerMillis,omitempty"` AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` DisplayDensity string `json:"displayDensity,omitempty"` Contrast string `json:"contrast,omitempty"` ReducedMotion bool `json:"reducedMotion,omitempty"` KeyboardFocus string `json:"keyboardFocus,omitempty"` AutofillPrivacy autofillPrivacySettings `json:"autofillPrivacy,omitempty"` } type autofillPrivacySettings struct { FirstFillApprovalMode string `json:"firstFillApprovalMode,omitempty"` BrowserAllowlist []string `json:"browserAllowlist,omitempty"` AppAllowlist []string `json:"appAllowlist,omitempty"` PackageRules []string `json:"packageRules,omitempty"` } type autofillFirstFillApprovalMode string const ( autofillFirstFillApprovalAsk autofillFirstFillApprovalMode = "ask" autofillFirstFillApprovalAllow autofillFirstFillApprovalMode = "allow" autofillFirstFillApprovalBlock autofillFirstFillApprovalMode = "block" ) type entriesSectionState struct { Path []string SearchQuery string SelectedEntryID string Editing bool } type syncSourceMode string const ( syncSourceLocal syncSourceMode = "local" syncSourceRemote syncSourceMode = "remote" ) type syncDirection string const ( syncDirectionPull syncDirection = "pull" syncDirectionPush syncDirection = "push" ) type ui struct { mode string theme *material.Theme logoHorizontal paint.ImageOp splashSquare paint.ImageOp search widget.Editor vaultPath widget.Editor saveAsPath widget.Editor remoteBaseURL widget.Editor remotePath widget.Editor remoteUsername widget.Editor remotePassword widget.Editor masterPassword widget.Editor keyFilePath widget.Editor apiTokenName widget.Editor apiTokenClientName widget.Editor apiTokenExpiresAt widget.Editor apiPolicyOperation widget.Editor apiPolicyPath widget.Editor apiPolicyEntryID widget.Editor securityCipher widget.Editor securityKDF widget.Editor entryID widget.Editor entryTitle widget.Editor entryUsername widget.Editor entryPassword widget.Editor entryURL widget.Editor entryNotes widget.Editor entryTags widget.Editor entryPath widget.Editor entryFields widget.Editor customFieldKeys []widget.Editor customFieldValues []widget.Editor historyIndex widget.Editor groupName widget.Editor groupParentPath widget.Editor passwordProfile widget.Editor attachmentName widget.Editor attachmentPath widget.Editor exportAttachmentPath widget.Editor autofillBrowserAllowlist widget.Editor autofillAppAllowlist widget.Editor autofillPackageRules widget.Editor list widget.List groupList widget.List detailList widget.List apiPolicyList widget.List lifecycleList widget.List phonePanelList widget.List securityDialogList widget.List remotePrefsDialogList widget.List recentVaultListState widget.List recentRemoteListState widget.List copyUser widget.Clickable copyPass widget.Clickable copyURL widget.Clickable lockVault widget.Clickable unlockVault widget.Clickable createVault widget.Clickable openVault widget.Clickable saveVault widget.Clickable saveAsVault widget.Clickable openRemote widget.Clickable changeMasterKey widget.Clickable synchronizeVault widget.Clickable toggleSyncMenu widget.Clickable toggleMainMenu widget.Clickable openAdvancedSync widget.Clickable openSecuritySettings widget.Clickable openRemotePrefsHelp widget.Clickable closeAdvancedSync widget.Clickable closeSecuritySettings widget.Clickable closeRemotePrefsHelp widget.Clickable runAdvancedSync widget.Clickable saveSecuritySettings widget.Clickable settingsDensityDense widget.Clickable settingsDensityComfortable widget.Clickable settingsContrastStandard widget.Clickable settingsContrastHigh widget.Clickable settingsReducedMotionOff widget.Clickable 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 pickKeyFile widget.Clickable pickSyncLocalPath widget.Clickable clearVaultSelection widget.Clickable clearRemoteSelection widget.Clickable dismissBanner widget.Clickable addEntry widget.Clickable saveEntry widget.Clickable duplicateEntry widget.Clickable deleteEntry widget.Clickable restoreEntry widget.Clickable saveTemplate widget.Clickable deleteTemplate widget.Clickable instantiateTemplate widget.Clickable addAttachment widget.Clickable replaceAttachment widget.Clickable removeAttachment widget.Clickable exportAttachment widget.Clickable restoreHistory widget.Clickable generatePassword widget.Clickable goToRootGroup widget.Clickable goToParentGroup widget.Clickable createGroup widget.Clickable moveGroup widget.Clickable renameGroup widget.Clickable deleteGroup widget.Clickable confirmDeleteGroup widget.Clickable cancelDeleteGroup widget.Clickable addCustomField widget.Clickable toggleGroupControls widget.Clickable toggleLifecycleAdvanced widget.Clickable toggleHistory widget.Clickable togglePasswordInline widget.Clickable toggleSyncPassword widget.Clickable setStatusBannerShort widget.Clickable setStatusBannerStandard widget.Clickable setStatusBannerLong widget.Clickable showAllAutofillNotices widget.Clickable showApprovalAutofillOnly widget.Clickable hideAutofillNotices widget.Clickable showEntries widget.Clickable showTemplates widget.Clickable showRecycle widget.Clickable showAPITokens widget.Clickable showAPIAudit widget.Clickable showLocalLifecycle widget.Clickable showRemoteLifecycle widget.Clickable showSyncLocal widget.Clickable showSyncRemote widget.Clickable showSyncPull widget.Clickable showSyncPush widget.Clickable showAutofillApprovalAsk widget.Clickable showAutofillApprovalAllow widget.Clickable showAutofillApprovalBlock widget.Clickable allowApproval widget.Clickable denyApproval widget.Clickable cancelApproval widget.Clickable cancelLifecycleProgress widget.Clickable retryLifecycleOpen widget.Clickable approvalPermanent widget.Bool rememberRemoteAuth widget.Bool apiPolicyAllow widget.Bool apiPolicyGroupScopeW widget.Bool apiTokenDisabled widget.Bool settingsGroupControls widget.Bool settingsLifecycleAdvanced widget.Bool settingsHistory widget.Bool settingsDenseLayout widget.Bool entryClicks []widget.Clickable apiTokenClicks []widget.Clickable apiPolicyRemoves []widget.Clickable apiAuditClicks []widget.Clickable apiAuditTokenFilters []widget.Clickable apiAuditDecisionFilters []widget.Clickable apiAuditOperationFilters []widget.Clickable clearAPIAuditFilters widget.Clickable historyClicks []widget.Clickable attachmentClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable recentVaultClicks []widget.Clickable recentRemoteClicks []widget.Clickable removeCustomFields []widget.Clickable state appstate.State visible []entry currentPath []string syncedPath []string selectedHistoryIndex int showPassword bool generatedPasswordDraft bool togglePassword widget.Clickable copyAPITokenSecret widget.Clickable issueAPIToken widget.Clickable saveAPIToken widget.Clickable rotateAPIToken widget.Clickable disableAPIToken widget.Clickable revokeAPIToken widget.Clickable deleteAPIToken widget.Clickable addAPIPolicyRule widget.Clickable phoneSplit widget.Float splitDrag gesture.Drag splitBase float32 splitStartY float32 phoneSpan int phoneGroupBrowserExpanded bool eyeIcon *widget.Icon eyeOffIcon *widget.Icon copyIcon *widget.Icon expandMoreIcon *widget.Icon expandLessIcon *widget.Icon chevronDownIcon *widget.Icon settingsIcon *widget.Icon menuIcon *widget.Icon clipboardWriter clipboard.Writer loadingMessage string loadingActionLabel string lifecycleMode string syncSourceMode syncSourceMode syncDirection syncDirection syncLocalPath widget.Editor syncRemoteBaseURL widget.Editor syncRemotePath widget.Editor syncRemoteUsername widget.Editor syncRemotePassword widget.Editor syncDialogOpen bool syncMenuOpen bool mainMenuOpen bool securityDialogOpen bool remotePrefsDialogOpen bool showSyncPassword bool 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 denseLayout bool statusBannerTTL time.Duration autofillNoticePreference autofillNoticeMode autofillFirstFillApprovalMode autofillFirstFillApprovalMode accessibilityPrefs accessibilityPreferences settingsDraft settingsDraft recentVaults []string recentRemotes []recentRemoteRecord recentVaultGroups map[string][]string recentVaultUsedAt map[string]time.Time entriesState entriesSectionState deleteGroupPath []string apiPolicyGroupScope bool apiTokenSecret string selectedAuditIndex int statusExpiresAt time.Time now func() time.Time apiHost *api.Host auditLog *apiaudit.Log grpcAddress string backgroundResults chan backgroundActionResult backgroundActionSerial int activeBackgroundAction int lastLifecycleAction string requestMasterPassFocus bool invalidate func() } type backgroundActionResult struct { label string apply func() error err error id int } var ( bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255} panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255} accentColor = color.NRGBA{R: 28, G: 83, B: 63, A: 255} mutedColor = color.NRGBA{R: 95, G: 93, B: 88, A: 255} selectedColor = color.NRGBA{R: 221, G: 233, B: 226, A: 255} selectedEdge = color.NRGBA{R: 73, G: 123, B: 100, A: 255} ) const ( errVaultPathRequired = "vault path is required" errSaveAsPathRequired = "save-as path is required" ) func newUIWithModel(mode string, model vault.Model) *ui { return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths("")) } func newUIWithSession(mode string, sess appstate.CurrentSession, paths ...statePaths) *ui { selected := defaultStatePaths("") if len(paths) > 0 { selected = paths[0] } return newUIWithState(mode, sess, selected) } func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) *ui { th := material.NewTheme() th.Palette.Bg = bgColor th.Palette.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255} th.Palette.ContrastBg = accentColor th.Palette.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255} u := &ui{ mode: mode, theme: th, logoHorizontal: paint.NewImageOp(keepassassets.MustPNG("keepassgo-logo-horizontal.png")), splashSquare: paint.NewImageOp(keepassassets.MustPNG("keepassgo-splash-square.png")), search: widget.Editor{ SingleLine: true, Submit: false, }, vaultPath: widget.Editor{SingleLine: true, Submit: false}, saveAsPath: widget.Editor{SingleLine: true, Submit: false}, remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, remotePath: widget.Editor{SingleLine: true, Submit: false}, remoteUsername: widget.Editor{SingleLine: true, Submit: false}, remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, syncLocalPath: widget.Editor{SingleLine: true, Submit: false}, syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, syncRemotePath: widget.Editor{SingleLine: true, Submit: false}, syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false}, syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, masterPassword: widget.Editor{SingleLine: true, Submit: false, InputHint: key.HintPassword}, keyFilePath: widget.Editor{SingleLine: true, Submit: false}, apiTokenName: widget.Editor{SingleLine: true, Submit: false}, apiTokenClientName: widget.Editor{SingleLine: true, Submit: false}, apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false}, apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false}, securityCipher: widget.Editor{SingleLine: true, Submit: false}, securityKDF: widget.Editor{SingleLine: true, Submit: false}, entryID: widget.Editor{SingleLine: true, Submit: false}, entryTitle: widget.Editor{SingleLine: true, Submit: false}, entryUsername: widget.Editor{SingleLine: true, Submit: false}, entryPassword: widget.Editor{SingleLine: true, Submit: false}, entryURL: widget.Editor{SingleLine: true, Submit: false}, entryNotes: widget.Editor{SingleLine: false, Submit: false}, entryTags: widget.Editor{SingleLine: true, Submit: false}, entryPath: widget.Editor{SingleLine: true, Submit: false}, entryFields: widget.Editor{SingleLine: false, Submit: false}, historyIndex: widget.Editor{SingleLine: true, Submit: false}, groupName: widget.Editor{SingleLine: true, Submit: false}, groupParentPath: widget.Editor{SingleLine: true, Submit: false}, passwordProfile: widget.Editor{SingleLine: true, Submit: false}, attachmentName: widget.Editor{SingleLine: true, Submit: false}, attachmentPath: widget.Editor{SingleLine: true, Submit: false}, exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false}, autofillBrowserAllowlist: widget.Editor{SingleLine: false, Submit: false}, autofillAppAllowlist: widget.Editor{SingleLine: false, Submit: false}, autofillPackageRules: widget.Editor{SingleLine: false, Submit: false}, list: widget.List{ List: layout.List{Axis: layout.Vertical}, }, groupList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, apiPolicyList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, phonePanelList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, securityDialogList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, remotePrefsDialogList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, recentVaultListState: widget.List{ List: layout.List{Axis: layout.Vertical}, }, recentRemoteListState: widget.List{ List: layout.List{Axis: layout.Vertical}, }, state: appstate.State{}, selectedHistoryIndex: -1, selectedAuditIndex: -1, lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, settingsPath: paths.SettingsPath, uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, lifecycleAdvancedHidden: true, historyHidden: true, statusBannerTTL: statusBannerDuration, accessibilityPrefs: defaultAccessibilityPreferences(), autofillFirstFillApprovalMode: autofillFirstFillApprovalAsk, now: time.Now, syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, syncDefaultSourceMode: syncSourceLocal, syncDefaultDirection: syncDirectionPull, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, } if mode == "phone" { u.groupControlsHidden = true } u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true u.state.Session = sess u.phoneSplit.Value = 0.46 u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility) u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore) u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess) u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown) u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings) u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu) u.passwordProfile.SetText("strong") u.securityCipher.SetText(vault.CipherChaCha20) u.securityKDF.SetText(vault.KDFArgon2) u.keyboardFocus = focusSearch u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() u.restoreStartupLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() u.loadSettings() u.loadSettingsFormFromPreferences() u.loadSettingsDraft() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.filter() u.syncAutofillCache() return u } func (u *ui) filter() { u.state.SetSearchQuery(u.search.Text()) visible, err := u.state.VisibleEntries() if err != nil { u.visible = nil return } u.visible = visible if len(u.entryClicks) < len(u.visible) { u.entryClicks = make([]widget.Clickable, len(u.visible)) } } func defaultStatePaths(stateDir string) statePaths { baseDir := strings.TrimSpace(stateDir) if baseDir == "" { baseDir = strings.TrimSpace(os.Getenv("KEEPASSGO_STATE_DIR")) } if baseDir == "" { configDir, err := os.UserConfigDir() if err != nil || strings.TrimSpace(configDir) == "" { configDir = os.TempDir() } baseDir = filepath.Join(configDir, "keepassgo") } return 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"), } } func resolveFlagOrEnv(flagValue, envName, fallback string) string { if value := strings.TrimSpace(flagValue); value != "" { return value } if value := strings.TrimSpace(os.Getenv(envName)); value != "" { return value } return fallback } func defaultModeForRuntime(goos string) string { if strings.EqualFold(strings.TrimSpace(goos), "android") { return "phone" } return "desktop" } func shouldUsePreviewWindowSize(mode, goos string) bool { return !strings.EqualFold(strings.TrimSpace(goos), "android") } func supportsDesktopFilePicker(goos string) bool { return !strings.EqualFold(strings.TrimSpace(goos), "android") } func (u *ui) selectedAttachmentItems() []attachmentItem { item, ok := u.selectedEntry() if !ok || len(item.Attachments) == 0 { return nil } items := make([]attachmentItem, 0, len(item.Attachments)) for name, content := range item.Attachments { items = append(items, attachmentItem{Name: name, Size: len(content)}) } slices.SortFunc(items, func(a, b attachmentItem) int { switch { case a.Name < b.Name: return -1 case a.Name > b.Name: return 1 default: return 0 } }) if len(u.attachmentClicks) < len(items) { u.attachmentClicks = make([]widget.Clickable, len(items)) } return items } func (u *ui) selectedAttachmentNames() []string { items := u.selectedAttachmentItems() names := make([]string, 0, len(items)) for _, item := range items { names = append(names, item.Name) } return names } func (u *ui) showEntriesSection() { u.resetPasswordPeek() u.state.ShowSection(appstate.SectionEntries) u.mainMenuOpen = false u.restoreEntriesSectionState() u.filter() } func (u *ui) showTemplatesSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionTemplates) u.mainMenuOpen = false u.filter() } func (u *ui) showRecycleBinSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionRecycleBin) u.mainMenuOpen = false u.filter() } func (u *ui) showAPITokensSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPITokens) u.mainMenuOpen = false u.loadSelectedAPITokenIntoEditor() u.filter() } func (u *ui) showAPIAuditSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPIAudit) u.mainMenuOpen = false u.selectedAuditIndex = -1 u.filter() } func (u *ui) returnToMainEntries() { u.clearDeleteGroupConfirmation() u.showEntriesSection() } func (u *ui) handlePhoneBack() bool { if u.mode != "phone" { return false } switch { case u.securityDialogOpen: u.securityDialogOpen = false case u.remotePrefsDialogOpen: u.remotePrefsDialogOpen = false case u.syncDialogOpen: u.syncDialogOpen = false case u.mainMenuOpen: u.mainMenuOpen = false case u.state.Section != appstate.SectionEntries: u.returnToMainEntries() default: return false } return true } func (u *ui) resetPasswordPeek() { u.showPassword = false } func (u *ui) childGroups() []string { u.state.SetSearchQuery(u.search.Text()) groups, err := u.state.ChildGroups() if err != nil { return nil } return groups } func (u *ui) passwordProfileOptionsText() string { return "Available profiles: " + strings.Join(passwords.DefaultProfileNames(), ", ") } func (u *ui) filteredTitles() []string { titles := make([]string, 0, len(u.visible)) for _, item := range u.visible { titles = append(titles, item.Title) } return titles } func (u *ui) visiblePathContexts() []string { paths := make([]string, 0, len(u.visible)) for _, item := range u.visible { paths = append(paths, u.state.SearchPathContext(item)) } return paths } func (u *ui) selectedEntry() (entry, bool) { for _, item := range u.visible { if item.ID == u.state.SelectedEntryID { return item, true } } model, err := u.state.Session.Current() if err != nil { return entry{}, false } for _, item := range model.Entries { if item.ID == u.state.SelectedEntryID { return item, true } } for _, item := range model.Templates { if item.ID == u.state.SelectedEntryID { return item, true } } for _, item := range model.RecycleBin { if item.ID == u.state.SelectedEntryID { return item, true } } return entry{}, false } func (u *ui) ensureHistoryClickables() { history := u.visibleHistory() if len(u.historyClicks) < len(history) { u.historyClicks = make([]widget.Clickable, len(history)) } } func (u *ui) currentMasterKey() (vault.MasterKey, error) { password := u.masterPassword.Text() path := strings.TrimSpace(u.keyFilePath.Text()) if password == "" && path == "" { return vault.MasterKey{}, fmt.Errorf("master password or key file is required") } if path == "" { return vault.MasterKey{Password: password}, nil } content, err := os.ReadFile(path) if err != nil { return vault.MasterKey{}, fmt.Errorf("read key file: %w", err) } if len(content) == 0 { return vault.MasterKey{}, fmt.Errorf("key file is empty") } return vault.MasterKey{ Password: password, KeyFileData: content, }, nil } func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {} func (u *ui) createVaultAction() error { key, err := u.currentMasterKey() defer u.clearMasterPassword() if err != nil { return err } if err := u.state.ConfigureSecurity(vault.SecuritySettings{ Cipher: strings.TrimSpace(u.securityCipher.Text()), KDF: strings.TrimSpace(u.securityKDF.Text()), }); err != nil { return err } if err := u.state.CreateVault(key); err != nil { return err } if u.lifecycleMode == "local" { if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil { return err } u.vaultPath.SetText(u.saveAsTargetPath()) u.noteRecentVault(u.saveAsTargetPath()) } u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil } func (u *ui) openVaultAction() error { key, err := u.currentMasterKey() defer u.clearMasterPassword() if err != nil { return err } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { return errors.New(errVaultPathRequired) } if err := u.state.OpenVault(path, key); err != nil { return err } u.noteRecentVault(path) u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil } func (u *ui) startOpenVaultAction() { manager, ok := u.state.Session.(*session.Manager) if !ok { u.runAction("open vault", u.openVaultAction) return } key, err := u.currentMasterKey() u.clearMasterPassword() if err != nil { u.state.ErrorMessage = u.describeActionError("open vault", err) u.requestMasterPassFocus = true return } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) u.requestMasterPassFocus = true return } u.lastLifecycleAction = "open vault" u.runBackgroundAction("open vault", func() (func() error, error) { prepared, err := session.PrepareLocalOpen(path, key) if err != nil { return nil, err } return func() error { manager.ApplyPreparedLocalOpen(prepared) u.noteRecentVault(path) u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil }, nil }) } func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err } u.filter() return nil } func (u *ui) saveAsAction() error { path := u.saveAsTargetPath() if err := u.state.SaveAs(path); err != nil { return err } u.vaultPath.SetText(path) u.noteRecentVault(path) u.filter() return nil } func (u *ui) openRemoteAction() error { key, err := u.currentMasterKey() defer u.clearMasterPassword() if err != nil { return err } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), Password: u.remotePassword.Text(), } if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()), strings.TrimSpace(u.remoteUsername.Text()), u.remotePassword.Text(), u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil } func (u *ui) startOpenRemoteAction() { manager, ok := u.state.Session.(*session.Manager) if !ok { u.runAction("open remote vault", u.openRemoteAction) return } key, err := u.currentMasterKey() u.clearMasterPassword() if err != nil { u.state.ErrorMessage = u.describeActionError("open remote vault", err) u.requestMasterPassFocus = true return } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), Password: u.remotePassword.Text(), } remotePath := strings.TrimSpace(u.remotePath.Text()) u.lastLifecycleAction = "open remote vault" u.runBackgroundAction("open remote vault", func() (func() error, error) { prepared, err := session.PrepareRemoteOpen(client, remotePath, key) if err != nil { return nil, err } return func() error { manager.ApplyPreparedRemoteOpen(prepared) u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), remotePath, strings.TrimSpace(u.remoteUsername.Text()), u.remotePassword.Text(), u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil }, nil }) } func (u *ui) lockAction() error { u.clearMasterPassword() if err := u.state.Lock(); err != nil { return err } u.requestMasterPassFocus = true u.currentPath = append([]string(nil), u.state.CurrentPath...) u.resetPasswordPeek() u.editingEntry = false u.filter() return nil } func (u *ui) unlockAction() error { key, err := u.currentMasterKey() defer u.clearMasterPassword() if err != nil { return err } if err := u.state.Unlock(key); err != nil { return err } u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil } func (u *ui) startUnlockAction() { manager, ok := u.state.Session.(*session.Manager) if !ok { u.runAction("unlock vault", u.unlockAction) return } key, err := u.currentMasterKey() u.clearMasterPassword() if err != nil { u.state.ErrorMessage = u.describeActionError("unlock vault", err) u.requestMasterPassFocus = true return } encoded := append([]byte(nil), manager.EncodedBytes()...) u.runBackgroundAction("unlock vault", func() (func() error, error) { prepared, err := session.PrepareUnlock(encoded, key) if err != nil { return nil, err } return func() error { manager.ApplyPreparedUnlock(prepared) u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() return nil }, nil }) } func (u *ui) changeMasterKeyAction() error { key, err := u.currentMasterKey() defer u.clearMasterPassword() if err != nil { return err } return u.state.ChangeMasterKey(key) } func (u *ui) loadSecuritySettingsFromSession() { settings, err := u.state.SecuritySettings() if err != nil { return } u.securityCipher.SetText(settings.Cipher) u.securityKDF.SetText(settings.KDF) } func (u *ui) clearMasterPassword() { u.masterPassword.SetText("") } func (u *ui) synchronizeAction() error { if err := u.state.Synchronize(); err != nil { return err } u.syncMenuOpen = false u.filter() return nil } 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: return u.advancedSyncToAction() default: return u.advancedSyncFromAction() } } func (u *ui) advancedSyncFromAction() error { switch u.syncSourceMode { case syncSourceRemote: client := webdav.Client{ BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), Username: strings.TrimSpace(u.syncRemoteUsername.Text()), Password: u.syncRemotePassword.Text(), } if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { return err } default: path := strings.TrimSpace(u.syncLocalPath.Text()) if path == "" { return errors.New(errVaultPathRequired) } if err := u.state.SynchronizeFromLocal(path); err != nil { return err } } u.syncDialogOpen = false u.showSyncPassword = false u.filter() return nil } func (u *ui) advancedSyncToAction() error { switch u.syncSourceMode { case syncSourceRemote: client := webdav.Client{ BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), Username: strings.TrimSpace(u.syncRemoteUsername.Text()), Password: u.syncRemotePassword.Text(), } if err := u.state.SynchronizeToRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { return err } default: path := strings.TrimSpace(u.syncLocalPath.Text()) if path == "" { return errors.New(errVaultPathRequired) } if err := u.state.SynchronizeToLocal(path); err != nil { return err } } u.syncDialogOpen = false u.showSyncPassword = false u.filter() return nil } func (u *ui) saveAsTargetPath() string { path := strings.TrimSpace(u.saveAsPath.Text()) if path != "" { return path } return u.defaultSaveAsPath } func (u *ui) noteRecentVault(path string) { path = strings.TrimSpace(path) if path == "" { return } if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } if u.recentVaultUsedAt == nil { u.recentVaultUsedAt = map[string]time.Time{} } if len(u.currentPath) > 0 { u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) } else if _, ok := u.recentVaultGroups[path]; !ok { u.recentVaultGroups[path] = nil } u.recentVaultUsedAt[path] = u.now() next := []string{path} for _, existing := range u.recentVaults { if existing == path { continue } next = append(next, existing) if len(next) == 6 { break } } u.recentVaults = next if len(u.recentVaultClicks) < len(u.recentVaults) { u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) } u.saveRecentVaults() } func (u *ui) loadRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return } content, err := os.ReadFile(u.recentVaultsPath) if err != nil { return } u.recentVaults = nil u.recentVaultGroups = map[string][]string{} u.recentVaultUsedAt = map[string]time.Time{} var records []recentVaultRecord switch { case json.Unmarshal(content, &records) == nil: u.applyRecentVaultRecords(records) return default: var paths []string if err := json.Unmarshal(content, &paths); err != nil { return } records = make([]recentVaultRecord, 0, len(paths)) for _, path := range paths { records = append(records, recentVaultRecord{Path: path}) } u.applyRecentVaultRecords(records) } } func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) { filtered := make([]string, 0, len(records)) seen := map[string]bool{} for _, record := range records { path := strings.TrimSpace(record.Path) if path == "" || seen[path] { continue } seen[path] = true filtered = append(filtered, path) if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } if u.recentVaultUsedAt == nil { u.recentVaultUsedAt = map[string]time.Time{} } u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...) if usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)); err == nil { u.recentVaultUsedAt[path] = usedAt } if len(filtered) == 6 { break } } u.recentVaults = filtered if len(u.recentVaultClicks) < len(u.recentVaults) { u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) } } func (u *ui) loadRecentRemotes() { if strings.TrimSpace(u.recentRemotesPath) == "" { return } content, err := os.ReadFile(u.recentRemotesPath) if err != nil { return } var records []recentRemoteRecord if err := json.Unmarshal(content, &records); err != nil { return } filtered := make([]recentRemoteRecord, 0, len(records)) seen := map[string]bool{} for _, record := range records { record.BaseURL = strings.TrimSpace(record.BaseURL) record.Path = strings.TrimSpace(record.Path) if record.BaseURL == "" || record.Path == "" { continue } key := record.BaseURL + "|" + record.Path if seen[key] { continue } seen[key] = true record.LastGroup = append([]string(nil), record.LastGroup...) filtered = append(filtered, record) if len(filtered) == 6 { break } } u.recentRemotes = filtered if len(u.recentRemoteClicks) < len(u.recentRemotes) { u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) } } func (u *ui) saveRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil { return } records := make([]recentVaultRecord, 0, len(u.recentVaults)) for _, path := range u.recentVaults { records = append(records, recentVaultRecord{ Path: path, LastGroup: append([]string(nil), u.recentVaultGroups[path]...), UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano), }) } content, err := json.MarshalIndent(records, "", " ") if err != nil { return } _ = os.WriteFile(u.recentVaultsPath, content, 0o600) } func (u *ui) saveRecentRemotes() { if strings.TrimSpace(u.recentRemotesPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil { return } content, err := json.MarshalIndent(u.recentRemotes, "", " ") if err != nil { return } _ = os.WriteFile(u.recentRemotesPath, content, 0o600) } func (u *ui) loadUIPreferences() { if strings.TrimSpace(u.uiPreferencesPath) == "" { return } content, err := os.ReadFile(u.uiPreferencesPath) if err != nil { return } var prefs uiPreferences if err := json.Unmarshal(content, &prefs); err != nil { return } u.groupControlsHidden = prefs.GroupControlsHidden u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden u.historyHidden = prefs.HistoryHidden u.denseLayout = prefs.DenseLayout u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) displayDensity := strings.TrimSpace(prefs.DisplayDensity) if displayDensity == "" { displayDensity = displayDensityForDenseLayout(prefs.DenseLayout) } u.applyAccessibilityPreferences(accessibilityPreferences{ DisplayDensity: displayDensity, Contrast: prefs.Contrast, ReducedMotion: prefs.ReducedMotion, KeyboardFocus: prefs.KeyboardFocus, }) if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" { u.autofillFirstFillApprovalMode = mode } u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist)) u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist)) u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules)) } func (u *ui) saveUIPreferences() { if strings.TrimSpace(u.uiPreferencesPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil { return } content, err := json.MarshalIndent(uiPreferences{ GroupControlsHidden: u.groupControlsHidden, LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, HistoryHidden: u.historyHidden, DenseLayout: u.denseLayout, StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), AutofillNoticeMode: string(u.autofillNoticePreference), DisplayDensity: u.accessibilityPrefs.DisplayDensity, Contrast: u.accessibilityPrefs.Contrast, ReducedMotion: u.accessibilityPrefs.ReducedMotion, KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, AutofillPrivacy: autofillPrivacySettings{ FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode), BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()), AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()), PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()), }, }, "", " ") if err != nil { return } _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) } func (u *ui) loadSettingsFormFromPreferences() { u.settingsGroupControls.Value = u.groupControlsHidden u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden u.settingsHistory.Value = u.historyHidden u.settingsDenseLayout.Value = u.denseLayout } func (u *ui) applySettingsFormToPreferences() { u.groupControlsHidden = u.settingsGroupControls.Value u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value u.historyHidden = u.settingsHistory.Value u.denseLayout = u.settingsDenseLayout.Value } func normalizedStatusBannerTTL(valueMillis int) time.Duration { switch { case valueMillis <= 0: return statusBannerDuration case time.Duration(valueMillis)*time.Millisecond > statusBannerLong: return statusBannerLong default: return time.Duration(valueMillis) * time.Millisecond } } func normalizedAutofillNoticeMode(value string) autofillNoticeMode { switch autofillNoticeMode(strings.TrimSpace(value)) { case autofillNoticeApprovals: return autofillNoticeApprovals case autofillNoticeSuppressed: return autofillNoticeSuppressed default: return autofillNoticeAll } } func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode { switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) { case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock: return autofillFirstFillApprovalMode(strings.TrimSpace(raw)) default: return "" } } func autofillPrivacyLines(text string) []string { lines := strings.Split(text, "\n") result := make([]string, 0, len(lines)) seen := make(map[string]struct{}, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if _, ok := seen[line]; ok { continue } seen[line] = struct{}{} result = append(result, line) } return result } func joinAutofillPrivacyLines(lines []string) string { if len(lines) == 0 { return "" } return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n") } func (u *ui) autofillRuleCount() int { return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) + len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) + len(autofillPrivacyLines(u.autofillPackageRules.Text())) } func (u *ui) autofillFirstFillApprovalSummary() string { switch u.autofillFirstFillApprovalMode { case autofillFirstFillApprovalAllow: return "New apps and packages can fill immediately until a persistent rule is created." case autofillFirstFillApprovalBlock: return "New apps and packages stay blocked until you add an allowlist entry or a package rule." default: return "KeePassGO asks before the first fill into a newly seen app or package." } } func (u *ui) setStatusBannerTTL(value time.Duration) { u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) u.saveUIPreferences() } func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value)) u.saveUIPreferences() } func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) if baseURL == "" || path == "" { return } record := recentRemoteRecord{ BaseURL: baseURL, Path: path, LastGroup: append([]string(nil), u.currentPath...), UsedAt: u.now().Format(time.RFC3339Nano), } if len(record.LastGroup) == 0 { record.LastGroup = u.recentRemoteGroup(baseURL, path) } if rememberAuth { record.Username = strings.TrimSpace(username) record.Password = password } next := []recentRemoteRecord{record} for _, existing := range u.recentRemotes { if existing.BaseURL == baseURL && existing.Path == path { continue } next = append(next, existing) if len(next) == 6 { break } } u.recentRemotes = next if len(u.recentRemoteClicks) < len(u.recentRemotes) { u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) } u.saveRecentRemotes() } func (u *ui) recentRemoteGroup(baseURL, path string) []string { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) for _, record := range u.recentRemotes { if record.BaseURL == baseURL && record.Path == path { return append([]string(nil), record.LastGroup...) } } return nil } func (u *ui) restoreStartupLifecycleTarget() { localPath, localUsedAt := u.latestRecentVault() remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() switch { case hasRemote && (localPath == "" || remoteUsedAt.After(localUsedAt)): u.lifecycleMode = "remote" u.applyRecentRemoteRecord(remoteRecord) case localPath != "": u.lifecycleMode = "local" u.vaultPath.SetText(localPath) } } func (u *ui) hasSelectedLifecycleTarget() bool { switch strings.TrimSpace(u.lifecycleMode) { case "remote": return strings.TrimSpace(u.remoteBaseURL.Text()) != "" && strings.TrimSpace(u.remotePath.Text()) != "" default: return strings.TrimSpace(u.vaultPath.Text()) != "" } } func (u *ui) latestRecentVault() (string, time.Time) { for _, path := range u.recentVaults { if strings.TrimSpace(path) == "" { continue } return path, u.recentVaultUsedAt[path] } return "", time.Time{} } func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { for _, record := range u.recentRemotes { if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { continue } usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)) if err != nil { usedAt = time.Time{} } return record, true, usedAt } return recentRemoteRecord{}, false, time.Time{} } func (u *ui) currentRemoteRecord() recentRemoteRecord { return recentRemoteRecord{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Path: strings.TrimSpace(u.remotePath.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), Password: u.remotePassword.Text(), } } func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) { record := u.currentRemoteRecord() if record.BaseURL == "" || record.Path == "" { return recentRemoteRecord{}, false } for _, existing := range u.recentRemotes { if existing.BaseURL == record.BaseURL && existing.Path == record.Path { return existing, true } } return recentRemoteRecord{}, false } func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) u.remoteUsername.SetText(record.Username) u.remotePassword.SetText(record.Password) u.remotePassword.Mask = '•' u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" } func (u *ui) remotePreferencesCurrentSummary() string { selected, hasSelected := u.selectedRecentRemoteRecord() switch { case !u.rememberRemoteAuth.Value: return "Current choice: KeePassGO will remember only the WebDAV location for this connection." case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""): return "Current choice: a successful open will update the saved sign-in for this connection on this device." case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": return "Current choice: a successful open will save the entered sign-in for this connection on this device." default: return "Current choice: sign-in retention is enabled, but no username or password is entered yet." } } func (u *ui) remotePreferencesAlwaysSavedSummary() string { return "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." } func (u *ui) remotePreferencesRetentionSummary() string { return "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." } func (u *ui) noteCurrentRemotePath() { status, ok := u.state.Session.(sessionStatus) if !ok || !status.IsRemote() || status.IsLocked() { return } baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) if baseURL == "" || path == "" { return } for i := range u.recentRemotes { if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path { continue } u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...) u.saveRecentRemotes() return } } func (u *ui) recentVaultGroup(path string) []string { if u.recentVaultGroups == nil { return nil } return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...) } func (u *ui) hiddenVaultRoot() string { if u.state.Section != appstate.SectionEntries { return "" } model, err := u.state.Session.Current() if err != nil { return "" } if len(model.EntriesInPath(nil)) != 0 { return "" } groups := model.ChildGroups(nil) if len(groups) != 1 { return "" } return groups[0] } func (u *ui) enterHiddenVaultRoot() { root := u.hiddenVaultRoot() if root == "" { return } u.setCurrentPath([]string{root}) } func (u *ui) restoreRecentVaultGroup(path string) { saved := u.recentVaultGroup(path) if len(saved) == 0 { u.enterHiddenVaultRoot() return } model, err := u.state.Session.Current() if err != nil { u.enterHiddenVaultRoot() return } root := u.hiddenVaultRoot() if len(saved) == 1 && root != "" && saved[0] == root { u.setCurrentPath(saved) return } if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { u.setCurrentPath(saved) return } u.enterHiddenVaultRoot() } func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { saved := u.recentRemoteGroup(baseURL, path) if len(saved) == 0 { u.enterHiddenVaultRoot() return } model, err := u.state.Session.Current() if err != nil { u.enterHiddenVaultRoot() return } root := u.hiddenVaultRoot() if len(saved) == 1 && root != "" && saved[0] == root { u.setCurrentPath(saved) return } if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { u.setCurrentPath(saved) return } u.enterHiddenVaultRoot() } func (u *ui) restoreEntriesPath(path []string) { if len(path) == 0 { u.enterHiddenVaultRoot() return } model, err := u.state.Session.Current() if err != nil { u.enterHiddenVaultRoot() return } root := u.hiddenVaultRoot() if len(path) == 1 && root != "" && path[0] == root { u.setCurrentPath(path) return } if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { u.setCurrentPath(path) return } u.enterHiddenVaultRoot() } func (u *ui) rememberEntriesSectionState() { if u.state.Section != appstate.SectionEntries { return } u.entriesState = entriesSectionState{ Path: append([]string(nil), u.currentPath...), SearchQuery: u.search.Text(), SelectedEntryID: u.state.SelectedEntryID, Editing: u.editingEntry, } } func (u *ui) restoreEntriesSectionState() { u.search.SetText(u.entriesState.SearchQuery) u.restoreEntriesPath(u.entriesState.Path) u.state.SelectedEntryID = u.entriesState.SelectedEntryID u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != "" if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" { u.loadSelectedEntryIntoEditor() } } func (u *ui) displayPath() []string { path := append([]string(nil), u.currentPath...) root := u.hiddenVaultRoot() if root == "" || len(path) == 0 || path[0] != root { return path } return append([]string(nil), path[1:]...) } func (u *ui) displayEntryPath(path []string) []string { root := u.hiddenVaultRoot() if root == "" || len(path) == 0 || path[0] != root { return append([]string(nil), path...) } return append([]string(nil), path[1:]...) } func (u *ui) currentGroupDisplayName() string { displayPath := u.displayPath() if len(displayPath) == 0 { return "Vault root (/)" } return strings.Join(displayPath, " / ") } func (u *ui) parentGroupDisplayName() string { displayPath := u.displayPath() if len(displayPath) <= 1 { return "Vault root (/)" } return strings.Join(displayPath[:len(displayPath)-1], " / ") } func (u *ui) createGroupLabel() string { if len(u.displayPath()) == 0 { return "Create Top-Level Group" } return "Create Subgroup" } func pathHasPrefix(path, prefix []string) bool { if len(prefix) > len(path) { return false } return slices.Equal(path[:len(prefix)], prefix) } func hasExactGroup(model vault.Model, path []string) bool { for _, group := range model.Groups { if slices.Equal(group, path) { return true } } return false } func (u *ui) currentGroupDeletionState() (bool, string) { u.syncCurrentPath() if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil { return false, "" } model, err := u.state.Session.Current() if err != nil { return false, "" } path := append([]string(nil), u.currentPath...) if len(model.ChildGroups(path)) > 0 { return false, "This group contains child groups. Move or delete them before removing the group." } for _, item := range model.Entries { if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { return false, "This group contains entries. Move or delete them before removing the group." } } for _, item := range model.Templates { if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { return false, "This group contains templates. Move or delete them before removing the group." } } return true, "Deleting this empty group will not remove any entries." } func (u *ui) deleteGroupPendingConfirmation() bool { return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) } func (u *ui) clearDeleteGroupConfirmation() { u.deleteGroupPath = nil } func (u *ui) armDeleteCurrentGroupAction() { if deletable, _ := u.currentGroupDeletionState(); !deletable { return } u.syncCurrentPath() u.deleteGroupPath = append([]string(nil), u.currentPath...) u.state.ErrorMessage = "" u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))) } func (u *ui) runAction(label string, action func() error) { if strings.TrimSpace(u.loadingMessage) != "" { return } u.loadingMessage = actionLoadingLabel(label) u.loadingActionLabel = strings.TrimSpace(label) if err := action(); err != nil { u.loadingMessage = "" u.loadingActionLabel = "" u.state.ErrorMessage = u.describeActionError(label, err) u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.loadingMessage = "" u.loadingActionLabel = "" u.syncAutofillCache() u.state.ErrorMessage = "" if suppressStatusMessage(label) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.showStatusMessage(label + " complete") } func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { if strings.TrimSpace(u.loadingMessage) != "" { return } u.backgroundActionSerial++ actionID := u.backgroundActionSerial u.activeBackgroundAction = actionID u.loadingMessage = actionLoadingLabel(label) u.loadingActionLabel = strings.TrimSpace(label) u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} go func() { apply, err := prepare() u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID} if u.invalidate != nil { u.invalidate() } }() } func (u *ui) applyBackgroundResult(result backgroundActionResult) { if result.id != 0 && result.id != u.activeBackgroundAction { return } u.activeBackgroundAction = 0 u.loadingMessage = "" u.loadingActionLabel = "" if result.err != nil { u.state.ErrorMessage = u.describeActionError(result.label, result.err) if strings.HasPrefix(result.label, "open ") { u.requestMasterPassFocus = true } u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } if result.apply != nil { if err := result.apply(); err != nil { u.state.ErrorMessage = u.describeActionError(result.label, err) if strings.HasPrefix(result.label, "open ") { u.requestMasterPassFocus = true } u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } } u.syncAutofillCache() u.state.ErrorMessage = "" if suppressStatusMessage(result.label) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.showStatusMessage(result.label + " complete") } func (u *ui) cancelLifecycleBusyState() { if !u.lifecycleBusy() { return } u.activeBackgroundAction = 0 u.loadingMessage = "" u.loadingActionLabel = "" u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} u.requestMasterPassFocus = true } func (u *ui) retryLastLifecycleOpen() { switch strings.TrimSpace(u.lastLifecycleAction) { case "open vault": u.startOpenVaultAction() case "open remote vault": u.startOpenRemoteAction() } } func (u *ui) canRetryLifecycleOpen() bool { if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" { return false } switch strings.TrimSpace(u.lastLifecycleAction) { case "open vault", "open remote vault": return true default: return false } } func (u *ui) processBackgroundActions() { for { select { case result := <-u.backgroundResults: u.applyBackgroundResult(result) default: return } } } func (u *ui) syncAutofillCache() { if strings.TrimSpace(u.autofillCachePath) == "" { return } model, err := u.state.Session.Current() if err != nil { _ = autofillcache.Clear(u.autofillCachePath) return } _ = autofillcache.Write(u.autofillCachePath, model, u.now()) } func suppressStatusMessage(label string) bool { switch strings.TrimSpace(label) { case "open vault", "open remote vault": return true default: return false } } func actionLoadingLabel(label string) string { label = strings.TrimSpace(label) if label == "" { return "Working..." } runes := []rune(label) runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] return string(runes) + "..." } func (u *ui) describeActionError(label string, err error) string { if err == nil { return "" } if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { return "Save conflict: the remote vault changed. Reopen it and retry the save." } if label == "open remote vault" { return fmt.Sprintf("%s failed: %v", label, err) } return err.Error() } func (u *ui) remoteOpenRetryAvailable() bool { return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:") } func (u *ui) remoteOpenButtonLabel() string { switch { case u.lifecycleBusy(): return "Opening Remote Vault..." case u.remoteOpenRetryAvailable(): return "Retry Remote Vault" default: return "Open Remote Vault" } } func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": return uiBanner{ Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage), Detail: u.loadingDetailMessage(), } case strings.TrimSpace(u.state.ErrorMessage) != "": return uiBanner{ Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage), Dismissable: true, } default: return uiBanner{} } } func (u *ui) statusToastSurface() uiBanner { if strings.TrimSpace(u.state.StatusMessage) == "" { return uiBanner{} } if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return uiBanner{} } return uiBanner{ Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage), } } func (u *ui) autofillStatusSurface() uiAutofillStatus { if u.autofillNoticePreference == autofillNoticeSuppressed { return uiAutofillStatus{} } if request, ok := u.pendingAutofillApproval(); ok { detail := approvalResourceText(request) if strings.TrimSpace(detail) == "" { detail = "Review the request to allow or deny this fill attempt." } return uiAutofillStatus{ Kind: autofillStatusAwaitingApproval, Title: "Autofill approval needed", Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.", Detail: detail, } } if u.auditLog == nil { return uiAutofillStatus{} } if u.autofillNoticePreference == autofillNoticeApprovals { return uiAutofillStatus{} } for _, event := range u.auditLog.Events() { if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { return status } } return uiAutofillStatus{} } func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) { for _, request := range u.state.PendingApprovals() { if isAutofillOperation(request.Operation) { return request, true } } return apiapproval.Request{}, false } func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) { if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL { return uiAutofillStatus{}, false } requester := formatAutofillRequester(event.ClientName, event.TokenName) switch event.Type { case apiaudit.EventAutofillFound: return uiAutofillStatus{ Kind: autofillStatusFound, Title: "Autofill match ready", Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."), Detail: autofillEventDetail(event), }, true case apiaudit.EventAutofillAmbiguous: return uiAutofillStatus{ Kind: autofillStatusAmbiguous, Title: "Autofill needs a narrower match", Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."), Detail: autofillEventDetail(event), }, true case apiaudit.EventAutofillBlocked: return uiAutofillStatus{ Kind: autofillStatusBlocked, Title: "Autofill is blocked", Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."), Detail: autofillEventDetail(event), }, true case apiaudit.EventApprovalAllowed: if !isAutofillOperation(event.Operation) { return uiAutofillStatus{}, false } return uiAutofillStatus{ Kind: autofillStatusFound, Title: "Autofill approved", Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."), Detail: autofillEventDetail(event), }, true case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut: if !isAutofillOperation(event.Operation) { return uiAutofillStatus{}, false } return uiAutofillStatus{ Kind: autofillStatusBlocked, Title: "Autofill was not allowed", Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)), Detail: autofillEventDetail(event), }, true default: return uiAutofillStatus{}, false } } func autofillEventDetail(event apiaudit.Event) string { return strings.TrimSpace(resourceDetailText(event.Resource)) } func resourceDetailText(resource apitokens.Resource) string { switch resource.Kind { case apitokens.ResourceEntry: if entryID := strings.TrimSpace(resource.EntryID); entryID != "" { return "Entry ID: " + entryID } case apitokens.ResourceGroup: if len(resource.Path) > 0 { return "Group: " + strings.Join(resource.Path, " / ") } } return "" } func formatAutofillRequester(clientName, tokenName string) string { switch { case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "": return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")" case strings.TrimSpace(clientName) != "": return strings.TrimSpace(clientName) case strings.TrimSpace(tokenName) != "": return strings.TrimSpace(tokenName) default: return "A trusted client" } } func defaultAutofillMessage(value, fallback string) string { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } return fallback } func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string { switch eventType { case apiaudit.EventApprovalDenied: return requester + " was denied for this fill request." case apiaudit.EventApprovalCanceled: return requester + " canceled this fill request." case apiaudit.EventApprovalTimedOut: return requester + " timed out while waiting for approval." default: return requester + " could not fill this target." } } func isAutofillOperation(operation apitokens.Operation) bool { switch operation { case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL: return true default: return false } } func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { if !u.shouldShowLifecycleSetup() { if banner.Dismissable { return "", "Dismiss" } return "", "" } switch banner.Kind { case bannerLoading: if strings.HasPrefix(u.loadingActionLabel, "open ") { return "Cancel", "" } case bannerError: if u.canRetryLifecycleOpen() { return "Retry", "Dismiss" } if banner.Dismissable { return "", "Dismiss" } } return "", "" } func (u *ui) loadingDetailMessage() string { if !u.shouldShowLifecycleSetup() { return "" } if u.lifecycleMode == "remote" { baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) switch { case baseURL != "" && path != "": return fmt.Sprintf( "Target: %s (%s)", friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), path, ) case baseURL != "": return "Target: " + baseURL default: return "Preparing remote vault access" } } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { return "Preparing local vault access" } return "Target: " + path } func (u *ui) currentVaultSummary() vaultSummary { status, ok := u.state.Session.(sessionStatus) if !ok || !status.HasVault() { return vaultSummary{} } if status.IsRemote() { baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) summary := vaultSummary{ Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), Detail: baseURL, } if strings.TrimSpace(summary.Title) == "" { summary.Title = "Remote vault" } summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path)) return summary } path := strings.TrimSpace(u.vaultPath.Text()) summary := vaultSummary{ Title: friendlyRecentVaultLabel(path), Detail: path, } if strings.TrimSpace(summary.Title) == "" { summary.Title = "Local vault" } summary.Context = u.vaultResumeContext(u.recentVaultGroup(path)) return summary } func (u *ui) vaultResumeContext(path []string) string { if len(path) == 0 { return "" } displayPath := append([]string(nil), path...) if len(displayPath) == 0 { return "" } return "Resume in: " + strings.Join(displayPath, " / ") } func compactPathDirectorySummary(path string) string { cleaned := filepath.Clean(strings.TrimSpace(path)) if cleaned == "." || cleaned == "" { return "" } dir := filepath.Dir(cleaned) if dir == "." || dir == cleaned { return "" } if dir == string(filepath.Separator) { return dir } parts := strings.Split(filepath.ToSlash(dir), "/") filtered := parts[:0] for _, part := range parts { if strings.TrimSpace(part) != "" { filtered = append(filtered, part) } } parts = filtered if len(parts) <= 2 { return filepath.ToSlash(dir) } return parts[0] + "/.../" + parts[len(parts)-1] } func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { if !u.requestMasterPassFocus { return } gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) gtx.Execute(op.InvalidateCmd{}) u.requestMasterPassFocus = false } func (u *ui) sessionSurface() uiSurface { if u.state.Session == nil { return uiSurface{} } if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { return uiSurface{ Title: "Vault locked", Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", Locked: true, } } return uiSurface{} } func (u *ui) hasOpenVault() bool { status, ok := u.state.Session.(sessionStatus) if ok { return status.HasVault() } _, err := u.state.Session.Current() return err == nil } func (u *ui) isVaultLocked() bool { status, ok := u.state.Session.(sessionStatus) if ok { return status.IsLocked() } _, err := u.state.Session.Current() return errors.Is(err, session.ErrLocked) } func (u *ui) shouldShowLifecycleSetup() bool { return !u.hasOpenVault() } func (u *ui) lifecycleBusy() bool { return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" } func (u *ui) shouldUseLockedSinglePane() bool { return u.isVaultLocked() && !u.shouldShowLifecycleSetup() } func (u *ui) shouldShowDesktopWorkingHeader() bool { return u.mode == "desktop" && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() } func (u *ui) shouldUseCompactPhoneDetailPane() bool { if u.mode != "phone" { return false } if u.isVaultLocked() || u.editingEntry { return false } _, ok := u.selectedEntry() return !ok } func (u *ui) chooseExistingFileAction(target *widget.Editor) error { path, err := pickExistingFile() if err != nil { return err } target.SetText(path) return nil } func (u *ui) listEmptyMessage() string { return u.listEmptyState().Body } func (u *ui) listEmptyState() emptyState { if surface := u.sessionSurface(); surface.Locked { return emptyState{ Title: "Vault locked", Body: "Unlock the vault to browse entries and groups.", } } query := strings.TrimSpace(u.search.Text()) if query != "" { switch u.state.Section { case appstate.SectionAPITokens: return emptyState{ Title: "No matching API tokens", Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search vault to find a token by name, client, or expiration.", query), } case appstate.SectionAPIAudit: return emptyState{ Title: "No matching audit events", Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query), } case appstate.SectionTemplates: return emptyState{ Title: "No matching templates", Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), } case appstate.SectionRecycleBin: return emptyState{ Title: "No matching deleted entries", Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), } default: return emptyState{ Title: "No matching entries", Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), } } } switch u.state.Section { case appstate.SectionAPITokens: return emptyState{ Title: "No API tokens yet", Body: "Issue a token to grant scoped gRPC access to an external tool.", } case appstate.SectionAPIAudit: return emptyState{ Title: "No API audit events yet", Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", } case appstate.SectionTemplates: return emptyState{ Title: "Templates unavailable", Body: "Templates are not available in this build.", } case appstate.SectionRecycleBin: return emptyState{ Title: "Recycle Bin is empty", Body: "Deleted entries will appear here until restored.", } default: if len(u.displayPath()) > 0 { return emptyState{ Title: "This group is empty", Body: "Add an entry here, search below this point, or open a subgroup.", } } return emptyState{ Title: "No entries yet", Body: "Create or open a vault, then add an entry to get started.", } } } func (u *ui) detailPlaceholderMessage() string { if surface := u.sessionSurface(); surface.Locked { return "Unlock the vault to inspect entries, attachments, and history." } if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { return "Complete the form to create a new item or update the current selection." } switch u.state.Section { case appstate.SectionAPITokens: return "Select an API token, issue a new one, or search to narrow the list." case appstate.SectionAPIAudit: return "Select an audit event to inspect it, or use Search vault or the quick filters above." case appstate.SectionTemplates: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: return "Select a deleted entry to review or restore it." default: if strings.TrimSpace(u.search.Text()) != "" { return "Select a matching entry from the filtered list or clear the search." } if len(u.displayPath()) == 0 { return "Select an entry from the vault root or open a group." } return "Select an entry or start a new one." } } func (u *ui) ensureNavClickables() { u.syncCurrentPath() if len(u.breadcrumbs) < len(u.currentPath)+1 { u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) } } func (u *ui) syncPhoneGroupBrowser(path []string) { if u.mode != "phone" { return } u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 } func (u *ui) setCurrentPath(path []string) { u.currentPath = append([]string(nil), path...) u.state.NavigateToPath(path) u.syncedPath = append([]string(nil), path...) u.syncPhoneGroupBrowser(path) u.noteCurrentVaultPath() u.clearDeleteGroupConfirmation() } func (u *ui) syncCurrentPath() { switch { case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): u.currentPath = append([]string(nil), u.state.CurrentPath...) case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): u.state.CurrentPath = append([]string(nil), u.currentPath...) case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): u.state.CurrentPath = append([]string(nil), u.currentPath...) } u.syncedPath = append([]string(nil), u.currentPath...) u.noteCurrentVaultPath() if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) { u.clearDeleteGroupConfirmation() } } func (u *ui) noteCurrentVaultPath() { status, ok := u.state.Session.(sessionStatus) if !ok || status.IsLocked() { return } if status.IsRemote() { u.noteCurrentRemotePath() return } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { return } if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) u.saveRecentVaults() } func (u *ui) layout(gtx layout.Context) layout.Dimensions { // Clear the full frame explicitly so mobile surfaces don't start from an // unpainted black buffer before nested background widgets run. paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) u.syncHostedAPI() u.filter() u.processShortcuts(gtx) for u.createVault.Clicked(gtx) { u.runAction("create vault", u.createVaultAction) } for u.openVault.Clicked(gtx) { u.startOpenVaultAction() } for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) } for u.saveAsVault.Clicked(gtx) { u.runAction("save-as vault", u.saveAsAction) } for u.openRemote.Clicked(gtx) { u.startOpenRemoteAction() } for u.changeMasterKey.Clicked(gtx) { u.runAction("change master key", u.changeMasterKeyAction) } for u.synchronizeVault.Clicked(gtx) { u.runAction("synchronize vault", u.synchronizeAction) } for u.toggleSyncMenu.Clicked(gtx) { u.syncMenuOpen = !u.syncMenuOpen } for u.toggleMainMenu.Clicked(gtx) { u.mainMenuOpen = !u.mainMenuOpen } for u.openAdvancedSync.Clicked(gtx) { u.openAdvancedSyncDialog() } for u.openSecuritySettings.Clicked(gtx) { u.loadSecuritySettingsFromSession() u.loadSettingsFormFromPreferences() u.loadSettingsDraft() u.mainMenuOpen = false u.securityDialogOpen = true } for u.openRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = true } for u.setStatusBannerShort.Clicked(gtx) { u.setStatusBannerTTL(2 * time.Second) } for u.setStatusBannerStandard.Clicked(gtx) { u.setStatusBannerTTL(statusBannerDuration) } for u.setStatusBannerLong.Clicked(gtx) { u.setStatusBannerTTL(statusBannerLong) } for u.showAllAutofillNotices.Clicked(gtx) { u.setAutofillNoticePreference(autofillNoticeAll) } for u.showApprovalAutofillOnly.Clicked(gtx) { u.setAutofillNoticePreference(autofillNoticeApprovals) } for u.hideAutofillNotices.Clicked(gtx) { u.setAutofillNoticePreference(autofillNoticeSuppressed) } for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false } for u.closeSecuritySettings.Clicked(gtx) { u.securityDialogOpen = false } for u.closeRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = false } for u.runAdvancedSync.Clicked(gtx) { u.runAction("advanced synchronize vault", u.advancedSyncAction) } for u.saveSecuritySettings.Clicked(gtx) { u.runAction("save settings", u.saveSecuritySettingsAction) } for u.settingsDensityDense.Clicked(gtx) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense } for u.settingsDensityComfortable.Clicked(gtx) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityComfortable } for u.settingsContrastStandard.Clicked(gtx) { u.settingsDraft.Accessibility.Contrast = contrastStandard } for u.settingsContrastHigh.Clicked(gtx) { u.settingsDraft.Accessibility.Contrast = contrastHigh } for u.settingsReducedMotionOff.Clicked(gtx) { u.settingsDraft.Accessibility.ReducedMotion = false } for u.settingsReducedMotionOn.Clicked(gtx) { u.settingsDraft.Accessibility.ReducedMotion = true } for u.settingsKeyboardFocusStandard.Clicked(gtx) { u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusStandard } for u.settingsKeyboardFocusProminent.Clicked(gtx) { u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent } for u.unlockVault.Clicked(gtx) { u.startUnlockAction() } for u.cancelLifecycleProgress.Clicked(gtx) { u.cancelLifecycleBusyState() } for u.retryLifecycleOpen.Clicked(gtx) { u.state.ErrorMessage = "" u.retryLastLifecycleOpen() } for u.showEntries.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showEntriesSection() } for u.showTemplates.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showTemplatesSection() } for u.showRecycle.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showRecycleBinSection() } for u.showAPITokens.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showAPITokensSection() } for u.showAPIAudit.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showAPIAuditSection() } for u.showLocalLifecycle.Clicked(gtx) { if u.lifecycleBusy() { continue } u.lifecycleMode = "local" u.requestMasterPassFocus = true } for u.showRemoteLifecycle.Clicked(gtx) { if u.lifecycleBusy() { continue } u.lifecycleMode = "remote" u.requestMasterPassFocus = true } for u.toggleLifecycleAdvanced.Clicked(gtx) { if u.lifecycleBusy() { continue } u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden u.saveUIPreferences() } for u.showSyncLocal.Clicked(gtx) { u.syncSourceMode = syncSourceLocal } for u.showSyncRemote.Clicked(gtx) { u.syncSourceMode = syncSourceRemote } for u.showSyncPull.Clicked(gtx) { u.syncDirection = syncDirectionPull } 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 } for u.showAutofillApprovalAllow.Clicked(gtx) { u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow } for u.showAutofillApprovalBlock.Clicked(gtx) { u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock } for u.allowApproval.Clicked(gtx) { u.runAction("allow API request", func() error { outcome := apiapproval.OutcomeAllowOnce if u.approvalPermanent.Value { outcome = apiapproval.OutcomeAllowPermanent } err := u.resolvePendingApproval(outcome) u.approvalPermanent.Value = false return err }) } for u.denyApproval.Clicked(gtx) { u.runAction("deny API request", func() error { outcome := apiapproval.OutcomeDenyOnce if u.approvalPermanent.Value { outcome = apiapproval.OutcomeDenyPermanent } err := u.resolvePendingApproval(outcome) u.approvalPermanent.Value = false return err }) } for u.cancelApproval.Clicked(gtx) { u.runAction("cancel API request", func() error { err := u.resolvePendingApproval(apiapproval.OutcomeCancel) u.approvalPermanent.Value = false return err }) } for u.lockVault.Clicked(gtx) { u.runAction("lock vault", u.lockAction) } for u.issueAPIToken.Clicked(gtx) { u.runAction("issue API token", u.issueAPITokenAction) } for u.saveAPIToken.Clicked(gtx) { u.runAction("save API token", u.saveAPITokenAction) } for u.rotateAPIToken.Clicked(gtx) { u.runAction("rotate API token", u.rotateAPITokenAction) } for u.disableAPIToken.Clicked(gtx) { u.runAction("disable API token", u.disableAPITokenAction) } for u.revokeAPIToken.Clicked(gtx) { u.runAction("revoke API token", u.revokeAPITokenAction) } for u.deleteAPIToken.Clicked(gtx) { u.runAction("delete API token", u.deleteAPITokenAction) } for u.addAPIPolicyRule.Clicked(gtx) { u.runAction("add API policy rule", u.addAPIPolicyRuleAction) } for i := range u.apiPolicyRemoves { for u.apiPolicyRemoves[i].Clicked(gtx) { index := i u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) } } for u.copyAPITokenSecret.Clicked(gtx) { secret := u.apiTokenSecret u.runAction("copy API token secret", func() error { if strings.TrimSpace(secret) == "" { return fmt.Errorf("no API token secret to copy") } if u.clipboardWriter != nil { return u.clipboardWriter.WriteText(secret) } return clipboard.WriteText(secret) }) } for u.editEntry.Clicked(gtx) { u.editingEntry = true u.loadSelectedEntryIntoEditor() } for u.cancelEdit.Clicked(gtx) { u.editingEntry = false u.loadSelectedEntryIntoEditor() } for u.pickVaultPath.Clicked(gtx) { if u.lifecycleBusy() { continue } u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) } for u.pickKeyFile.Clicked(gtx) { if u.lifecycleBusy() { continue } u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } for u.pickSyncLocalPath.Clicked(gtx) { u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) }) } for i := range u.recentVaultClicks { for u.recentVaultClicks[i].Clicked(gtx) { if u.lifecycleBusy() { continue } if i < len(u.recentVaults) { u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[i]) u.requestMasterPassFocus = true } } } for i := range u.recentRemoteClicks { for u.recentRemoteClicks[i].Clicked(gtx) { if u.lifecycleBusy() { continue } if i < len(u.recentRemotes) { u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[i]) u.requestMasterPassFocus = true } } } for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue } u.vaultPath.SetText("") u.state.ErrorMessage = "" u.state.StatusMessage = "" u.requestMasterPassFocus = true } for u.clearRemoteSelection.Clicked(gtx) { if u.lifecycleBusy() { continue } u.remoteBaseURL.SetText("") u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") u.rememberRemoteAuth.Value = false u.state.ErrorMessage = "" u.state.StatusMessage = "" u.requestMasterPassFocus = true } for u.dismissBanner.Clicked(gtx) { u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} } for u.addEntry.Clicked(gtx) { u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.editingEntry = true } for u.saveEntry.Clicked(gtx) { u.runAction("save entry", u.saveEntryAction) } for u.duplicateEntry.Clicked(gtx) { u.runAction("duplicate entry", u.duplicateSelectedEntryAction) } for u.deleteEntry.Clicked(gtx) { u.runAction("delete entry", u.deleteSelectedEntryAction) } for u.restoreEntry.Clicked(gtx) { u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) } for u.saveTemplate.Clicked(gtx) { u.runAction("save template", u.saveTemplateAction) } for u.deleteTemplate.Clicked(gtx) { u.runAction("delete template", u.deleteSelectedTemplateAction) } for u.instantiateTemplate.Clicked(gtx) { u.runAction("instantiate template", u.instantiateSelectedTemplateAction) } for u.addAttachment.Clicked(gtx) { u.runAction("add attachment", u.addAttachmentAction) } for u.replaceAttachment.Clicked(gtx) { u.runAction("replace attachment", u.replaceAttachmentAction) } for u.removeAttachment.Clicked(gtx) { u.runAction("remove attachment", u.removeAttachmentAction) } for u.exportAttachment.Clicked(gtx) { u.runAction("export attachment", u.exportAttachmentAction) } for u.copyUser.Clicked(gtx) { u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) } for u.copyPass.Clicked(gtx) { u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) } for u.copyURL.Clicked(gtx) { u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) } for u.generatePassword.Clicked(gtx) { u.runAction("generate password", u.generatePasswordAction) } for u.restoreHistory.Clicked(gtx) { u.runAction("restore history", u.restoreSelectedHistoryAction) } for u.createGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("create group", u.createGroupAction) } for u.moveGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("move group", u.moveCurrentGroupAction) u.currentPath = append([]string(nil), u.state.CurrentPath...) u.syncedPath = append([]string(nil), u.state.CurrentPath...) u.filter() } for u.toggleGroupControls.Clicked(gtx) { u.groupControlsHidden = !u.groupControlsHidden u.saveUIPreferences() } for u.toggleHistory.Clicked(gtx) { u.historyHidden = !u.historyHidden u.saveUIPreferences() } for u.renameGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("rename group", u.renameGroupAction) } for u.deleteGroup.Clicked(gtx) { u.armDeleteCurrentGroupAction() } for u.confirmDeleteGroup.Clicked(gtx) { u.runAction("delete group", u.deleteCurrentGroupAction) u.clearDeleteGroupConfirmation() } for u.cancelDeleteGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} } for u.togglePassword.Clicked(gtx) { u.showPassword = !u.showPassword } for u.togglePasswordInline.Clicked(gtx) { u.showPassword = !u.showPassword } for u.toggleSyncPassword.Clicked(gtx) { u.showSyncPassword = !u.showSyncPassword if u.showSyncPassword { u.syncRemotePassword.Mask = 0 } else { u.syncRemotePassword.Mask = '•' } } if _, changed := u.search.Update(gtx); changed { u.filter() } inset := layout.UniformInset(unit.Dp(16)) return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.header), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.bannerSurface().Kind == bannerNone { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(u.banner), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.bannerSurface().Kind != bannerNone { return layout.Dimensions{} } if u.autofillStatusSurface().Kind == autofillStatusNone { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(u.autofillStatusCard), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), ) }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if u.shouldShowLifecycleSetup() { return u.lifecycleScreen(gtx) } if u.shouldUseLockedSinglePane() { return u.detailPanel(gtx) } if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) if listHeight < gtx.Dp(unit.Dp(180)) { listHeight = gtx.Dp(unit.Dp(180)) } if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) { listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.Y = listHeight gtx.Constraints.Max.Y = listHeight return u.listPanel(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(u.phoneSlider), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), func() layout.FlexChild { if u.shouldUseCompactPhoneDetailPane() { return layout.Rigid(u.detailPanel) } return layout.Flexed(1, u.detailPanel) }(), ) } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Flexed(0.38, u.listPanel), layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), layout.Flexed(0.62, u.detailPanel), ) }), ) }) }) }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { if !u.syncDialogOpen { return layout.Dimensions{} } return u.syncDialog(gtx) }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { if !u.securityDialogOpen { return layout.Dimensions{} } return u.securityDialog(gtx) }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { if !u.remotePrefsDialogOpen { return layout.Dimensions{} } return u.remotePrefsDialog(gtx) }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { if _, ok := u.pendingApproval(); !ok { return layout.Dimensions{} } return u.approvalDialog(gtx) }), layout.Stacked(u.statusToast), ) } func (u *ui) syncHostedAPI() { if u.apiHost == nil { return } if err := u.apiHost.SyncFromLifecycle(); err != nil { u.state.ErrorMessage = fmt.Sprintf("sync gRPC API: %v", err) } } func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { panel := card if u.mode == "phone" { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ u.lifecycleBranding, layout.Spacer{Height: unit.Dp(8)}.Layout, u.lifecycleControls, } return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) }) } func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(620)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.syncDialogContent) }) }), ) } func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(620)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.securityDialogContent) }) }), ) } func (u *ui) remotePrefsDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(660)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.remotePrefsDialogContent) }) }), ) } func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Settings") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "ACCESSIBILITY") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Display Density", "Adjust editor height and control spacing for denser or roomier forms.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsDensityDense, Label: "Dense", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense}, choiceSpec{Click: &u.settingsDensityComfortable, Label: "Comfortable", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityComfortable}, ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Contrast", "Increase focus and selection contrast without changing unrelated vault behavior.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsContrastStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.Contrast == contrastStandard}, choiceSpec{Click: &u.settingsContrastHigh, Label: "High Contrast", Active: u.settingsDraft.Accessibility.Contrast == contrastHigh}, ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Reduced Motion", "Keep transient status toasts steady instead of auto-dismissing after a short timeout.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsReducedMotionOff, Label: "Off", Active: !u.settingsDraft.Accessibility.ReducedMotion}, choiceSpec{Click: &u.settingsReducedMotionOn, Label: "On", Active: u.settingsDraft.Accessibility.ReducedMotion}, ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Keyboard Focus", "Strengthen the visible focus ring and focused selection treatment for keyboard-first navigation.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsKeyboardFocusStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusStandard}, choiceSpec{Click: &u.settingsKeyboardFocusProminent, Label: "Prominent", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusProminent}, ) }) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { 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) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "UI Preferences") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsGroupControls, "Keep Group Tools collapsed") return check.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsLifecycleAdvanced, "Keep advanced lifecycle controls collapsed") return check.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed") return check.Layout(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "Vault Security") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false), layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false), layout.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Sync Defaults"), layout.Spacer{Height: unit.Dp(6)}.Layout, 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.Spacer{Height: unit.Dp(8)}.Layout, 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.Spacer{Height: unit.Dp(8)}.Layout, 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.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return syncDialogSummaryCard(gtx, u.theme, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) }, layout.Spacer{Height: unit.Dp(8)}.Layout, 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.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Background Sync"), layout.Spacer{Height: unit.Dp(6)}.Layout, 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.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Feedback"), layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Success and reminder banners") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Choose how long noncritical status banners stay visible.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, 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.setStatusBannerShort, "Short", u.statusBannerTTL == 2*time.Second) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.setStatusBannerStandard, "Standard", u.statusBannerTTL == statusBannerDuration) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.setStatusBannerLong, "Long", u.statusBannerTTL == statusBannerLong) }), ) }, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Autofill notices") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Keep recent autofill results visible, reduce them to approval-only, or hide them.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, 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.showAllAutofillNotices, "All", u.autofillNoticePreference == autofillNoticeAll) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showApprovalAutofillOnly, "Approval Only", u.autofillNoticePreference == autofillNoticeApprovals) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.hideAutofillNotices, "Hidden", u.autofillNoticePreference == autofillNoticeSuppressed) }), ) }, layout.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Privacy"), layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return settingsSummaryCard(gtx, u.theme, "PRIVACY PLAN", "Use first-fill approval plus browser/app rules to keep autofill constrained to trusted targets.") }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "First-fill approval") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), u.autofillFirstFillApprovalSummary()) lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, 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.showAutofillApprovalAsk, "Ask First", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAsk) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAllow, "Allow First Fill", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAllow) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalBlock, "Block Until Allowed", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalBlock) }), ) }, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d autofill rule entries configured across browsers, apps, and package-specific overrides.", u.autofillRuleCount())) lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Browser allowlist", "One origin or hostname per line for trusted browser surfaces.", &u.autofillBrowserAllowlist, false), layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "App and package allowlist", "One Android package name or trusted app identifier per line.", &u.autofillAppAllowlist, false), layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Package rules", "One rule per line, for example `com.android.chrome=hostname` or `org.keepassgo.browser=view-id`.", &u.autofillPackageRules, false), layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Close") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Settings") }), ) }, } return material.List(u.theme, &u.securityDialogList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) } func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Remote Connection Settings & Help") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Use Recent Connections to reopen WebDAV-backed vaults quickly without cluttering the main open flow.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Current", u.remotePreferencesCurrentSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Always Saved", u.remotePreferencesAlwaysSavedSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Retention", u.remotePreferencesRetentionSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "When Sign-in Saves", "Username and password or app token are only stored after a successful remote open when Remember sign-in is enabled.", "")(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.closeRemotePrefsHelp, "Done") }, } return material.List(u.theme, &u.remotePrefsDialogList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) } func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 110}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(640)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.approvalDialogContent) }) }), ) } func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions { request, ok := u.pendingApproval() if !ok { return layout.Dimensions{} } resourceText := approvalResourceText(request) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Approve API Request") lbl.Color = accentColor return lbl.Layout(gtx) }), 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), "An external tool requested vault access that is not explicitly allowed or denied.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Client", strings.TrimSpace(request.ClientName), strings.TrimSpace(request.TokenName))(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent") check.Color = accentColor return check.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.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 tonedButton(gtx, u.theme, &u.allowApproval, "Allow") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.denyApproval, "Deny") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel") }), ) }), ) } func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Advanced Sync") lbl.Color = accentColor return lbl.Layout(gtx) }), 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. Saved source and direction defaults now live in Settings.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.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.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.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.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncDialogSummaryCard(gtx, u.theme, u.syncSourceMode, u.syncDirection) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.syncSourceMode == syncSourceRemote { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.syncPasswordField(gtx) }), ) } if supportsDesktopFilePicker(runtime.GOOS) { return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx) } return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.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 tonedButton(gtx, u.theme, &u.runAdvancedSync, "Synchronize") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel") }), ) }), ) }) } func (u *ui) pendingApproval() (apiapproval.Request, bool) { pending := u.state.PendingApprovals() if len(pending) == 0 { return apiapproval.Request{}, false } return pending[0], true } func (u *ui) resolvePendingApproval(outcome apiapproval.Outcome) error { request, ok := u.pendingApproval() if !ok { return fmt.Errorf("no pending approval") } return u.state.ResolveApproval(request.ID, outcome) } func approvalResourceText(request apiapproval.Request) string { switch request.Resource.Kind { case apitokens.ResourceEntry: if strings.TrimSpace(request.Resource.EntryID) != "" { return "Entry " + request.Resource.EntryID } case apitokens.ResourceGroup: if len(request.Resource.Path) > 0 { return strings.Join(request.Resource.Path, " / ") } } return "Vault root" } func approvalFact(theme *material.Theme, title, primary, secondary string) layout.Widget { return 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(theme, unit.Sp(12), strings.ToUpper(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(theme, unit.Sp(16), strings.TrimSpace(primary)) lbl.Color = theme.Palette.Fg return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(secondary) == "" { return layout.Dimensions{} } lbl := material.Label(theme, unit.Sp(13), strings.TrimSpace(secondary)) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } } func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { field := func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, false, field) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.") lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } func (u *ui) header(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { if u.shouldShowLifecycleSetup() || u.isVaultLocked() { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, u.headerActions) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !u.mainMenuOpen { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, u.mainMenu) }), ) } if u.shouldShowDesktopWorkingHeader() { return layout.Dimensions{} } return card(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.brandMark(gtx, 196, 56) }), layout.Rigid(u.headerActions), ) }) } func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { if u.shouldShowLifecycleSetup() { return layout.Dimensions{} } if u.isVaultLocked() { return layout.Dimensions{} } if u.shouldShowDesktopWorkingHeader() { return layout.Dimensions{} } row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.menuIcon if icon == nil { icon = u.settingsIcon } btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") btn.Background = selectedColor btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) }), ) } if u.mode == "phone" { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }), layout.Rigid(row), ) } return row(gtx) } func (u *ui) mainMenu(gtx layout.Context) 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 { return tonedButton(gtx, u.theme, &u.showEntries, "Entries") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }), ) }) } func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { label := "Sync" spacing := unit.Dp(4) if u.mode == "phone" { spacing = unit.Dp(3) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.syncMenuToggle(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !u.syncMenuOpen { return layout.Dimensions{} } return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.syncMenu) }), ) } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) if u.mode == "phone" { btn.Size = unit.Dp(16) btn.Inset = layout.UniformInset(unit.Dp(7)) } return btn.Layout(gtx) } func (u *ui) syncMenu(gtx layout.Context) 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(u.theme, unit.Sp(11), "Need another source or direction?") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }), ) }) } func (u *ui) sectionSpacing() unit.Dp { if u.mode == "phone" { if u.denseLayout { return unit.Dp(4) } return unit.Dp(6) } if u.denseLayout { return unit.Dp(8) } return unit.Dp(12) } func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, unit.Dp) { inset := unit.Dp(12) titleSize := unit.Sp(17) metaSize := unit.Sp(14) urlSize := unit.Sp(12) pathSize := unit.Sp(11) dividerGap := unit.Dp(7) if u.denseLayout { inset = unit.Dp(9) titleSize = unit.Sp(15) metaSize = unit.Sp(12) urlSize = unit.Sp(11) pathSize = unit.Sp(10) dividerGap = unit.Dp(5) } if u.mode == "phone" { inset = unit.Dp(9) titleSize = unit.Sp(15) metaSize = unit.Sp(12) urlSize = unit.Sp(11) pathSize = unit.Sp(10) dividerGap = unit.Dp(5) if u.denseLayout { inset = unit.Dp(8) titleSize = unit.Sp(14) metaSize = unit.Sp(11) urlSize = unit.Sp(10) pathSize = unit.Sp(9) dividerGap = unit.Dp(4) } } return inset, titleSize, metaSize, urlSize, pathSize, dividerGap } func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := u.sectionSpacing() if u.mode == "phone" { panel = compactCard } u.ensureNavClickables() if u.mode == "phone" { return panel(gtx, func(gtx layout.Context) layout.Dimensions { rows := make([]layout.Widget, 0, 16+len(u.visible)) rows = append(rows, func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.X = gtx.Constraints.Max.X return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, "Search vault") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) }) }) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) if !u.isVaultLocked() { rows = append(rows, u.navigationHeader) if u.state.Section == appstate.SectionEntries { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) } } if !u.isVaultLocked() && u.state.Section == appstate.SectionRecycleBin { rows = append(rows, u.recycleBinSectionNotice) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) } if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { rows = append(rows, u.pathBar) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) } if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries { rows = append(rows, u.groupBar) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) rows = append(rows, u.groupControlsSection) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) } if !u.isVaultLocked() { rows = append(rows, func(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionEntries: btn := material.Button(u.theme, &u.addEntry, "+ Add Entry") return btn.Layout(gtx) case appstate.SectionAPITokens: return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") default: return layout.Dimensions{} } }) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) } switch { case u.state.Section == appstate.SectionAPITokens: rows = append(rows, u.apiTokenListPanel) case u.state.Section == appstate.SectionAPIAudit: rows = append(rows, u.apiAuditListPanel) case len(u.visible) == 0: rows = append(rows, func(gtx layout.Context) layout.Dimensions { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) }) default: for i := range u.visible { idx := i rows = append(rows, func(gtx layout.Context) layout.Dimensions { return u.entryRow(gtx, &u.entryClicks[idx], idx, u.visible[idx]) }) } } return material.List(u.theme, &u.phonePanelList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) }) } return panel(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() { return layout.Dimensions{} } return u.navigationHeader(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin { return layout.Dimensions{} } return u.recycleBinSectionNotice(gtx) }), layout.Rigid(layout.Spacer{Height: func() unit.Dp { if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin { return 0 } return spacing }()}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} } return u.pathBar(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupBar(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsSection(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, "Search vault") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) }) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() { return layout.Dimensions{} } switch u.state.Section { case appstate.SectionEntries: label := "Add Entry" if u.mode == "phone" { label = "+ " + label } btn := material.Button(u.theme, &u.addEntry, label) return btn.Layout(gtx) case appstate.SectionAPITokens: return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") default: return layout.Dimensions{} } }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAPITokens { return u.apiTokenListPanel(gtx) } if u.state.Section == appstate.SectionAPIAudit { return u.apiAuditListPanel(gtx) } if len(u.visible) == 0 { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) } return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions { item := u.visible[i] click := &u.entryClicks[i] return u.entryRow(gtx, click, i, item) }) }), ) }) } func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { if u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsDisclosure(gtx) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.sectionBar(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsDisclosure(gtx) }), ) } func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { tabs := []struct { click *widget.Clickable label string compact string active bool }{ {click: &u.showEntries, label: "Entries", compact: "Entries", active: u.state.Section == appstate.SectionEntries}, {click: &u.showRecycle, label: "Recycle Bin", compact: "Recycle", active: u.state.Section == appstate.SectionRecycleBin}, {click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens}, {click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit}, } if u.mode == "phone" { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].compact, tabs[0].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].compact, tabs[1].active) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].compact, tabs[2].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].compact, tabs[3].active) }), ) }), ) } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].label, tabs[0].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].label, tabs[1].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].label, tabs[2].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].label, tabs[3].active) }), ) } func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { for click.Clicked(gtx) { _ = u.state.ToggleVisibleIndex(idx) u.loadSelectedEntryIntoEditor() } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics() selected := item.ID == u.state.SelectedEntryID focused := u.isFocused(listFocusID(idx)) rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin) row := func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions { showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin hasUsername := strings.TrimSpace(item.Username) != "" hasURL := strings.TrimSpace(item.URL) != "" pathText := strings.Join(u.displayEntryPath(item.Path), " / ") return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, titleSize, item.Title) lbl.Color = rowColors.Title return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasUsername { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasUsername { return layout.Dimensions{} } lbl := material.Label(u.theme, metaSize, item.Username) lbl.Color = rowColors.Meta return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasURL { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasURL { return layout.Dimensions{} } lbl := material.Label(u.theme, urlSize, item.URL) lbl.Color = rowColors.Secondary return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showPath { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showPath { return layout.Dimensions{} } lbl := material.Label(u.theme, pathSize, pathText) lbl.Color = rowColors.Secondary return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { w := gtx.Constraints.Max.X if w < 1 { w = 1 } paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op()) return layout.Dimensions{Size: image.Pt(w, 1)} }), ) }) } if selected || focused { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op()) paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return row(gtx) }), ) } bg := panelColor if u.state.Section == appstate.SectionRecycleBin { bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255} } return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return row(gtx) }) }) } func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { if u.mode != "phone" { return layout.Dimensions{} } for { e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical) if !ok { break } switch e.Kind { case pointer.Press: u.splitBase = u.phoneSplit.Value u.splitStartY = e.Position.Y case pointer.Drag: if u.phoneSpan > 0 { next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan) if next < 0.28 { next = 0.28 } if next > 0.72 { next = 0.72 } u.phoneSplit.Value = next } } } gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24)) gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(24)) return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() u.splitDrag.Add(gtx.Ops) pointer.CursorRowResize.Add(gtx.Ops) handleW := gtx.Dp(unit.Dp(108)) handleH := gtx.Dp(unit.Dp(6)) x := (gtx.Constraints.Min.X - handleW) / 2 y := (gtx.Constraints.Min.Y - handleH) / 2 paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+2), Max: image.Pt(gtx.Constraints.Min.X, y+3)}.Op()) paint.FillShape(gtx.Ops, accentColor, clip.RRect{ Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)}, NE: 2, NW: 2, SE: 2, SW: 2, }.Op(gtx.Ops)) return layout.Dimensions{Size: gtx.Constraints.Min} }) } func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { panel := card if u.mode == "phone" { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { if u.shouldShowDesktopWorkingHeader() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }), layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.menuIcon if icon == nil { icon = u.settingsIcon } btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") btn.Background = selectedColor btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !u.mainMenuOpen { return layout.Dimensions{} } return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.mainMenu) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") return btn.Layout(gtx) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.detailPanelContent(gtx) }), ) } return u.detailPanelContent(gtx) }) } func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { panel := layout.Flex{Axis: layout.Vertical} _ = panel return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { if u.isVaultLocked() { summary := u.currentVaultSummary() return []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault") lbl.Color = accentColor return lbl.Layout(gtx) }), 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), "Enter the master password, choose a key file, or provide both.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(summary.Title) == "" { return 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(u.theme, unit.Sp(11), "UNLOCK TARGET") 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(u.theme, unit.Sp(15), summary.Title) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(summary.Detail) == "" || summary.Detail == summary.Title { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), summary.Detail) lbl.Color = mutedColor return lbl.Layout(gtx) }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(summary.Context) == "" { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), summary.Context) lbl.Color = mutedColor return lbl.Layout(gtx) }) }), ) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(u.unlockPanel), } } if u.state.Section == appstate.SectionAPITokens { return []layout.FlexChild{ layout.Flexed(1, u.apiTokenDetailPanel), } } if u.state.Section == appstate.SectionAPIAudit { return []layout.FlexChild{ layout.Flexed(1, u.apiAuditDetailPanel), } } item, ok := u.selectedEntry() if !ok && !u.editingEntry { return []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(18), "Entry details") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), u.detailPlaceholderMessage()) lbl.Color = mutedColor return lbl.Layout(gtx) }), } } if u.editingEntry { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { title := "New Entry" if ok { title = "Edit Entry" } lbl := material.Label(u.theme, unit.Sp(18), title) lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, u.entryEditorPanel, } return []layout.FlexChild{ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) }), } } password := u.detailPasswordValue() titleSize := unit.Sp(26) titlePad := unit.Dp(10) sectionGap := unit.Dp(6) cardGap := unit.Dp(8) if u.denseLayout { titlePad = unit.Dp(6) sectionGap = unit.Dp(4) cardGap = unit.Dp(6) } if u.mode == "phone" { titleSize = unit.Sp(18) titlePad = unit.Dp(4) sectionGap = unit.Dp(4) cardGap = unit.Dp(6) if u.denseLayout { titlePad = unit.Dp(3) sectionGap = unit.Dp(3) cardGap = unit.Dp(4) } } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { title := item.Title if u.state.Section == appstate.SectionRecycleBin { title = "Recycle Bin Entry" } lbl := material.Label(u.theme, titleSize, title) lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: titlePad}.Layout, func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionRecycleBin { if u.mode == "phone" { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { return compactTonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), ) } return layout.Dimensions{} } return recycleDetailTitle(gtx, u.theme, item.Title) }, layout.Spacer{Height: func() unit.Dp { if u.state.Section == appstate.SectionRecycleBin { return unit.Dp(10) } return 0 }()}.Layout, func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionRecycleBin { return layout.Dimensions{} } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "This entry is in the recycle bin. Review it, copy from it, or restore it back into the vault.") lbl.Color = mutedColor return lbl.Layout(gtx) }) }, layout.Spacer{Height: func() unit.Dp { if u.state.Section == appstate.SectionRecycleBin { return unit.Dp(8) } return 0 }()}.Layout, func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / "))), layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), layout.Rigid(detailLine(u.theme, "Username", item.Username)), layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), layout.Rigid(detailLine(u.theme, "URL", item.URL)), layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), layout.Rigid(detailLine(u.theme, "Tags", strings.Join(item.Tags, ", "))), ) }) }, layout.Spacer{Height: sectionGap}.Layout, func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.passwordLine("Password", password)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL") } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }), ) }), ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) 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(u.theme, unit.Sp(12), "NOTES") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, item.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }, layout.Spacer{Height: cardGap}.Layout, u.attachmentSummaryPanel, layout.Spacer{Height: cardGap}.Layout, u.historyPanel, layout.Spacer{Height: cardGap}.Layout, func(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionTemplates: return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") }), ) case appstate.SectionRecycleBin: return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry To Vault") default: return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.editEntry, "Edit") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") }), ) } }, } return []layout.FlexChild{ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) }), } }()...) } func (u *ui) banner(gtx layout.Context) layout.Dimensions { banner := u.bannerSurface() if banner.Kind == bannerNone { return layout.Dimensions{} } bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255} fg := accentColor switch banner.Kind { case bannerLoading: bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255} fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255} case bannerError: bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255} fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255} } primaryAction, secondaryAction := u.bannerActionLabels(banner) return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, 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(u.theme, unit.Sp(14), banner.Message) lbl.Color = fg return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(banner.Detail) == "" { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), banner.Detail) lbl.Color = fg return lbl.Layout(gtx) }) }), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if primaryAction == "" && secondaryAction == "" { return layout.Dimensions{} } return layout.Inset{Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if primaryAction == "" { return layout.Dimensions{} } click := &u.cancelLifecycleProgress if primaryAction == "Retry" { click = &u.retryLifecycleOpen } return tonedButton(gtx, u.theme, click, primaryAction) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if primaryAction == "" || secondaryAction == "" { return layout.Dimensions{} } return layout.Spacer{Width: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if secondaryAction == "" { return layout.Dimensions{} } return tonedButton(gtx, u.theme, &u.dismissBanner, secondaryAction) }), ) }) }), ) }) }) } func (u *ui) statusToast(gtx layout.Context) layout.Dimensions { status := u.statusToastSurface() if status.Kind == bannerNone { return layout.Dimensions{} } max := gtx.Constraints.Max gtx.Constraints.Min = max return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)} }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.End}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return statusToastCard(gtx, u.theme, status.Message) }), ) }), ) }) } func (u *ui) autofillStatusCard(gtx layout.Context) layout.Dimensions { status := u.autofillStatusSurface() if status.Kind == autofillStatusNone { return layout.Dimensions{} } bg := color.NRGBA{R: 233, G: 241, B: 237, A: 255} accent := accentColor switch status.Kind { case autofillStatusAmbiguous: bg = color.NRGBA{R: 245, G: 239, B: 223, A: 255} accent = color.NRGBA{R: 117, G: 88, B: 24, A: 255} case autofillStatusBlocked: bg = color.NRGBA{R: 247, G: 232, B: 228, A: 255} accent = color.NRGBA{R: 125, G: 40, B: 30, A: 255} case autofillStatusAwaitingApproval: bg = color.NRGBA{R: 229, G: 236, B: 244, A: 255} accent = color.NRGBA{R: 30, G: 76, B: 128, A: 255} } return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Right: unit.Dp(12), Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(12), "Autofill") label.Color = accent label.Font.Weight = 600 return label.Layout(gtx) }) }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(14), status.Title) label.Color = accent label.Font.Weight = 600 return label.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(12), status.Message) label.Color = color.NRGBA{R: 52, G: 50, B: 46, A: 255} return label.Layout(gtx) }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(status.Detail) == "" { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(11), status.Detail) label.Color = mutedColor return label.Layout(gtx) }) }), ) }), ) }) }) } func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions { history := u.visibleHistory() u.ensureHistoryClickables() children := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.toggleHistory.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.expandLessIcon if u.historyHidden { icon = u.expandMoreIcon } if icon != nil { return icon.Layout(gtx, accentColor) } lbl := material.Label(u.theme, unit.Sp(16), ">") if !u.historyHidden { lbl.Text = "v" } lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "History") lbl.Color = accentColor return lbl.Layout(gtx) }), ) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), } if u.historyHidden { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d saved version(s).", len(history))) lbl.Color = mutedColor return lbl.Layout(gtx) })) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } if len(history) == 0 { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "No history for this entry yet.") lbl.Color = mutedColor return lbl.Layout(gtx) })) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } for i := range history { index := i children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.historyRow(gtx, &u.historyClicks[index], index, history[index]) })) children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } if selected, ok := u.selectedHistoryEntry(); ok { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Selected Version") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Path", strings.Join(selected.Path, " / "))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Username", selected.Username)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "URL", selected.URL)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body2(u.theme, selected.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } func (u *ui) attachmentSummaryPanel(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() 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(u.theme, unit.Sp(12), "ATTACHMENTS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), u.attachmentActionSummary()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(items) == 0 { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(items)*2) selectedName := strings.TrimSpace(u.attachmentName.Text()) for i, item := range items { index := i name := item.Name selected := selectedName == name children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.attachmentClicks[index].Clicked(gtx) { u.attachmentName.SetText(name) } return u.attachmentClicks[index].Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Dp(unit.Dp(58)) } bg := panelColor if selected { bg = selectedColor } paint.FillShape(gtx.Ops, bg, clip.Rect{Max: size}.Op()) if selected { paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) } return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(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(u.theme, unit.Sp(12), name) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { text := fmt.Sprintf("%d B", item.Size) if selected { text += " · selected" } lbl := material.Label(u.theme, unit.Sp(11), text) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }), ) }) })) if i < len(items)-1 { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } } return children }()...) }) }), ) }) } func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions { for click.Clicked(gtx) { _ = u.selectHistoryVersion(index) } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { row := func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(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(u.theme, unit.Sp(13), fmt.Sprintf("Version %d", index)) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), item.Username) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), item.URL) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body2(u.theme, item.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) } if index == u.selectedHistoryIndex { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(row), ) } return layout.Background{}.Layout(gtx, fill(panelColor), row) }) } func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries stay here until you restore them.") } u.syncCurrentPath() displayPath := u.displayPath() pathSource := displayPath if u.state.Section == appstate.SectionTemplates { pathSource = append([]string{}, u.currentPath...) } crumbs, indices := u.visibleBreadcrumbs(pathSource) return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(crumbs)*2) for i, name := range crumbs { index := i label := name children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.breadcrumbs[index].Clicked(gtx) { target := indices[index] if target == 0 { root := u.hiddenVaultRoot() if root == "" { u.setCurrentPath(nil) } else { u.setCurrentPath([]string{root}) } } else { nextPath := pathSource[:target] root := u.hiddenVaultRoot() if root != "" { nextPath = append([]string{root}, nextPath...) } u.setCurrentPath(nextPath) } u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(11) if u.mode == "phone" { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} } else { btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} } return btn.Layout(gtx) })) if i < len(crumbs)-1 { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "/") lbl.Color = mutedColor inset := unit.Dp(6) if u.mode == "phone" { inset = unit.Dp(4) } return layout.UniformInset(inset).Layout(gtx, lbl.Layout) })) } } return children }()...) } func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { if u.state.Section == appstate.SectionTemplates { return append([]string{"Templates"}, append([]string{}, u.currentPath...)...), func() []int { indices := make([]int, 0, len(u.currentPath)+1) indices = append(indices, 0) for i := range u.currentPath { indices = append(indices, i+1) } return indices }() } if u.mode != "phone" || len(displayPath) <= 1 { crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) indices := make([]int, 0, len(crumbs)) indices = append(indices, 0) for i := range displayPath { indices = append(indices, i+1) } return crumbs, indices } if len(displayPath) == 2 { return []string{"/", displayPath[len(displayPath)-1]}, []int{0, len(displayPath)} } crumbs := []string{"/", "…", displayPath[len(displayPath)-1]} indices := []int{0, len(displayPath) - 1, len(displayPath)} return crumbs, indices } func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { groups := append([]string{}, u.childGroups()...) if len(u.groupClicks) < len(groups) { u.groupClicks = make([]widget.Clickable, len(groups)) } displayPath := u.displayPath() atRoot := len(displayPath) == 0 return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { if atRoot { u.phoneGroupBrowserExpanded = true } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "GROUPS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(groups) == 0 { lbl := material.Label(u.theme, unit.Sp(12), "No subgroups here.") lbl.Color = mutedColor return lbl.Layout(gtx) } maxY := gtx.Dp(unit.Dp(168)) if gtx.Constraints.Max.Y > maxY { gtx.Constraints.Max.Y = maxY } if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions { idx := i name := groups[i] return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { u.state.EnterGroup(name) u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() } return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) }) }) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "GROUPS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if atRoot { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.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 { for u.goToRootGroup.Clicked(gtx) { root := u.hiddenVaultRoot() if root == "" { u.setCurrentPath(nil) } else { u.setCurrentPath([]string{root}) } u.filter() } return tonedButton(gtx, u.theme, &u.goToRootGroup, "Back to Root") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.goToParentGroup.Clicked(gtx) { u.setCurrentPath(u.currentPath[:len(u.currentPath)-1]) u.filter() } return tonedButton(gtx, u.theme, &u.goToParentGroup, "Up One Group") }), ) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(groups) == 0 { lbl := material.Label(u.theme, unit.Sp(12), "No groups here.") lbl.Color = mutedColor return lbl.Layout(gtx) } maxGroupListHeight := 200 if u.mode == "phone" { maxGroupListHeight = 96 } maxY := gtx.Dp(unit.Dp(maxGroupListHeight)) if gtx.Constraints.Max.Y > maxY { gtx.Constraints.Max.Y = maxY } if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions { idx := i name := groups[i] return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { u.state.EnterGroup(name) u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() } return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) }) }) }), ) }) } func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { valueSize = unit.Sp(15) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, valueSize, value) return lbl.Layout(gtx) }), ) } } func (u *ui) passwordLine(label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { valueSize = unit.Sp(15) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, valueSize, value) return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.inlinePasswordToggle(gtx, &u.togglePasswordInline, u.showPassword) }), ) }), ) } } func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, showing bool) layout.Dimensions { icon := u.eyeIcon desc := "Show password" if showing { icon = u.eyeOffIcon desc = "Hide password" } btn := material.IconButton(u.theme, click, icon, desc) btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) } func (u *ui) detailPasswordValue() string { item, ok := u.selectedEntry() if !ok { return "" } if u.showPassword { return item.Password } return strings.Repeat("•", max(8, len(item.Password))) } func card(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, w) }) } func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, w) }) } func emptyStatePanel(gtx layout.Context, th *material.Theme, state emptyState) 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(15), state.Title) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(13), state.Body) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) } func outlinedFieldStateWithPrefs(gtx layout.Context, prefs accessibilityPreferences, focused bool, w layout.Widget) layout.Dimensions { appearance := fieldFocusAppearance(gtx.Metric, prefs, focused) size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = appearance.MinHeight } gtx.Constraints.Min = size return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{R: 255, G: 253, B: 249, A: 255}, clip.Rect{Max: size}.Op()) return layout.Dimensions{Size: size} }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { return drawFocusOutline(gtx, appearance, size) }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { min := gtx.Constraints.Min gtx.Constraints.Min = image.Point{} dims := w(gtx) if dims.Size.X < min.X { dims.Size.X = min.X } if dims.Size.Y < min.Y { dims.Size.Y = min.Y } if dims.Size.Y < appearance.MinHeight { dims.Size.Y = appearance.MinHeight } return dims }), ) } func (u *ui) outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { return outlinedFieldStateWithPrefs(gtx, u.accessibilityPrefs, focused, w) } func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { btn.TextSize = unit.Sp(14) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10} } return btn.Layout(gtx) } func compactTonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(13) btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 8, Right: 8} return btn.Layout(gtx) } func syncPrimaryButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, compact bool) layout.Dimensions { btn := material.Button(th, click, label) btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} btn.Color = accentColor btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(14) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 12, Right: 12} if compact { btn.TextSize = unit.Sp(13) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10} } return btn.Layout(gtx) } func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions { btn := material.Button(th, click, label) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(14) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 11, Right: 11} if active { btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} } else { btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} btn.Color = accentColor } return btn.Layout(gtx) } func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) lbl.Color = mutedColor return lbl.Layout(gtx) } } func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSourceMode, direction syncDirection) layout.Dimensions { sourceLabel := "another local vault file" if source == syncSourceRemote { sourceLabel = "another WebDAV-backed vault" } action := "Pull changes from" if direction == syncDirectionPush { action = "Push the current vault into" } return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(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), "SYNC PLAN") 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(14), action+" "+sourceLabel+".") lbl.Color = th.Palette.Fg return lbl.Layout(gtx) }), ) }) }) } func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(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), strings.ToUpper(title)) lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255} 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 = mutedColor return lbl.Layout(gtx) }), ) }) }) } func (u *ui) recycleBinSectionNotice(gtx layout.Context) layout.Dimensions { return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries are separated from normal browsing so you can review or restore them safely.") } func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(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), "DELETED ENTRY") lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255} return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(16), title) return lbl.Layout(gtx) }), ) }) }) } func statusToastCard(gtx layout.Context, th *material.Theme, message string) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 27, G: 58, B: 47, A: 235}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(13), message) lbl.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} return lbl.Layout(gtx) }) }) } func sectionTabButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions { btn := material.Button(th, click, label) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(11) btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 7, Right: 7} } if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(220)) { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 4, Bottom: 4, Left: 6, Right: 6} } if active { btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} } else { btn.Background = selectedColor btn.Color = accentColor } return btn.Layout(gtx) } func fill(c color.NRGBA) layout.Widget { return func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, c, clip.Rect{Max: gtx.Constraints.Min}.Op()) return layout.Dimensions{Size: gtx.Constraints.Min} } } func main() { mode := flag.String("mode", "", "window mode: desktop or phone") stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") flag.Parse() resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) width := unit.Dp(1180) height := unit.Dp(760) if strings.EqualFold(resolvedMode, "phone") { // Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview. width = unit.Dp(412) height = unit.Dp(915) } go func() { w := new(app.Window) options := []app.Option{app.Title(productName)} if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) { options = append(options, app.Size(width, height)) } w.Option(options...) if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { panic(err) } if !strings.EqualFold(runtime.GOOS, "android") { os.Exit(0) } }() app.Main() } func defaultGRPCAddr(goos string) string { if strings.EqualFold(strings.TrimSpace(goos), "android") { return "off" } return "127.0.0.1:47777" } func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { var ops op.Ops manager := &session.Manager{} ui := newUIWithSession(mode, manager, paths) ui.invalidate = w.Invalidate host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) if err != nil { ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) } else if host != nil { ui.apiHost = host ui.auditLog = host.Server().AuditLog() ui.grpcAddress = host.Address() ui.state.Approvals = &uiApprovalManager{server: host.Server()} defer func() { _ = host.Stop() }() } for { e := w.Event() switch e := e.(type) { case app.DestroyEvent: return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) ui.processBackgroundActions() ui.layout(gtx) e.Frame(gtx.Ops) } } } type uiApprovalManager struct { server *api.Server } func (m *uiApprovalManager) Pending() []apiapproval.Request { if m == nil || m.server == nil { return nil } return m.server.ApprovalBroker().Pending() } func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { if m == nil || m.server == nil { return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") } return m.server.ResolveApproval(id, outcome) } type uiSession struct { model vault.Model locked bool } func (s *uiSession) HasVault() bool { return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked } func (s *uiSession) IsLocked() bool { return s.locked } func (s *uiSession) IsRemote() bool { return false } func (s *uiSession) Current() (vault.Model, error) { if s.locked { return vault.Model{}, session.ErrLocked } return s.model, nil } func (s *uiSession) Replace(model vault.Model) { s.model = model } func (s *uiSession) Lock() error { s.locked = true return nil } func (s *uiSession) Unlock(vault.MasterKey) error { if !s.locked { return nil } s.locked = false return nil } func pickExistingFile() (string, error) { if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil { return path, nil } if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil { return path, nil } return "", fmt.Errorf("no supported file picker found; install kdialog or zenity") } func runFilePicker(name string, args ...string) (string, error) { if _, err := exec.LookPath(name); err != nil { return "", err } cmd := exec.Command(name, args...) output, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(output)), nil }