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/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" keepassassets "git.julianfamily.org/keepassgo/assets" "git.julianfamily.org/keepassgo/autofillcache" "git.julianfamily.org/keepassgo/appstate" "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 ) type bannerKind string const ( bannerNone bannerKind = "" bannerLoading bannerKind = "loading" bannerError bannerKind = "error" bannerStatus bannerKind = "status" ) type uiBanner struct { Kind bannerKind Message string } type uiSurface struct { Title string Message string Locked bool } 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 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"` } 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 list widget.List groupList widget.List detailList widget.List lifecycleList 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 openAdvancedSync widget.Clickable openSecuritySettings widget.Clickable closeAdvancedSync widget.Clickable closeSecuritySettings widget.Clickable runAdvancedSync widget.Clickable saveSecuritySettings widget.Clickable editEntry widget.Clickable cancelEdit widget.Clickable pickVaultPath widget.Clickable pickKeyFile widget.Clickable pickSyncLocalPath 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 createGroup widget.Clickable moveGroup widget.Clickable renameGroup widget.Clickable deleteGroup widget.Clickable confirmDeleteGroup widget.Clickable cancelDeleteGroup widget.Clickable addCustomField widget.Clickable toggleGroupControls widget.Clickable togglePasswordInline widget.Clickable toggleSyncPassword 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 allowApproval widget.Clickable denyApproval widget.Clickable cancelApproval widget.Clickable approvalPermanent widget.Bool rememberRemoteAuth widget.Bool apiPolicyAllow widget.Bool apiPolicyGroupScopeW widget.Bool apiTokenDisabled widget.Bool entryClicks []widget.Clickable apiTokenClicks []widget.Clickable apiPolicyRemoves []widget.Clickable apiAuditClicks []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 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 eyeIcon *widget.Icon eyeOffIcon *widget.Icon copyIcon *widget.Icon expandMoreIcon *widget.Icon expandLessIcon *widget.Icon chevronDownIcon *widget.Icon clipboardWriter clipboard.Writer loadingMessage 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 securityDialogOpen bool showSyncPassword bool keyboardFocus focusID defaultSaveAsPath string recentVaultsPath string uiPreferencesPath string recentRemotesPath string autofillCachePath string editingEntry bool groupControlsHidden bool 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 } 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: '•'}, 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: '•'}, masterPassword: widget.Editor{SingleLine: true, Submit: false}, 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}, 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}, }, lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, state: appstate.State{}, selectedHistoryIndex: -1, selectedAuditIndex: -1, lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, now: time.Now, syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, apiPolicyGroupScope: 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.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.loadUIPreferences() 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"), 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 (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.restoreEntriesSectionState() u.filter() } func (u *ui) showTemplatesSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionTemplates) u.filter() } func (u *ui) showRecycleBinSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionRecycleBin) u.filter() } func (u *ui) showAPITokensSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPITokens) u.loadSelectedAPITokenIntoEditor() u.filter() } func (u *ui) showAPIAuditSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPIAudit) u.selectedAuditIndex = -1 u.filter() } 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) 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) lockAction() error { u.clearMasterPassword() if err := u.state.Lock(); err != nil { return err } 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) 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) saveSecuritySettingsAction() error { settings := vault.SecuritySettings{ Cipher: strings.TrimSpace(u.securityCipher.Text()), KDF: strings.TrimSpace(u.securityKDF.Text()), } if err := u.state.ConfigureSecurity(settings); err != nil { return err } u.securityDialogOpen = false return nil } 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 if strings.TrimSpace(u.syncLocalPath.Text()) == "" { u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) } } 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 } 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, }, "", " ") if err != nil { return } _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) } 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) 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) 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) 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 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.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")) u.statusExpiresAt = u.now().Add(statusBannerDuration) } func (u *ui) runAction(label string, action func() error) { u.loadingMessage = actionLoadingLabel(label) if err := action(); err != nil { u.loadingMessage = "" u.state.ErrorMessage = u.describeActionError(label, err) u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.loadingMessage = "" u.syncAutofillCache() u.state.ErrorMessage = "" if suppressStatusMessage(label) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.state.StatusMessage = label + " complete" u.statusExpiresAt = u.now().Add(statusBannerDuration) } 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) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)} case strings.TrimSpace(u.state.ErrorMessage) != "": return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)} case strings.TrimSpace(u.state.StatusMessage) != "": 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)} default: return uiBanner{} } } 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) 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 { if surface := u.sessionSurface(); surface.Locked { return "Unlock the vault to browse entries and groups." } query := strings.TrimSpace(u.search.Text()) if query != "" { switch u.state.Section { case appstate.SectionTemplates: return fmt.Sprintf("No templates match %q. Clear or refine the search.", query) case appstate.SectionRecycleBin: return fmt.Sprintf("No recycle-bin entries match %q. Clear or refine the search.", query) default: return fmt.Sprintf("No entries match %q. Clear or refine the search.", query) } } switch u.state.Section { case appstate.SectionAPITokens: return "No API tokens match the current filter." case appstate.SectionAPIAudit: return "No API audit events match the current filter." case appstate.SectionTemplates: return "Templates are not available in this build." case appstate.SectionRecycleBin: return "Recycle Bin is empty." default: return "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 or issue a new one." case appstate.SectionAPIAudit: return "Select an audit event to inspect it." case appstate.SectionTemplates: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: return "Select a recycle-bin entry to review or restore it." default: 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) setCurrentPath(path []string) { u.currentPath = append([]string(nil), path...) u.state.NavigateToPath(path) u.syncedPath = append([]string(nil), 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.runAction("open vault", u.openVaultAction) } 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.runAction("open remote vault", u.openRemoteAction) } 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.openAdvancedSync.Clicked(gtx) { u.openAdvancedSyncDialog() } for u.openSecuritySettings.Clicked(gtx) { u.loadSecuritySettingsFromSession() u.securityDialogOpen = true } for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false } for u.closeSecuritySettings.Clicked(gtx) { u.securityDialogOpen = false } for u.runAdvancedSync.Clicked(gtx) { u.runAction("advanced synchronize vault", u.advancedSyncAction) } for u.saveSecuritySettings.Clicked(gtx) { u.runAction("save security settings", u.saveSecuritySettingsAction) } for u.unlockVault.Clicked(gtx) { u.runAction("unlock vault", u.unlockAction) } 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) { u.lifecycleMode = "local" } for u.showRemoteLifecycle.Clicked(gtx) { u.lifecycleMode = "remote" } 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.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) { u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) } for u.pickKeyFile.Clicked(gtx) { 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 i < len(u.recentVaults) { u.vaultPath.SetText(u.recentVaults[i]) } } } for i := range u.recentRemoteClicks { for u.recentRemoteClicks[i].Clicked(gtx) { if i < len(u.recentRemotes) { u.applyRecentRemoteRecord(u.recentRemotes[i]) } } } 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.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.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 _, ok := u.pendingApproval(); !ok { return layout.Dimensions{} } return u.approvalDialog(gtx) }), ) } 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) securityDialogContent(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(20), "Security Settings") 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), "Choose the KDBX cipher and KDF family KeePassGO should use for new or future saves.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(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, "Cancel") }), 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 Security Settings") }), ) }), ) } 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), "Synchronize...") 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), "Choose another source and whether to pull it into the current vault or push the current vault to it.") 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 layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showSyncPull, "Pull From Source") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showSyncPush, "Push To Source") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showSyncLocal, "Local File") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV") }), ) }), 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) }), ) } return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", 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 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 compactCard(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, 132, 42) }), layout.Rigid(u.headerActions), ) }) } 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{} } 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 { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Security") }), 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) }), ) } func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize") btn.CornerRadius = unit.Dp(10) return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.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 = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) 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 { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Synchronize...") }), ) }) } func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := unit.Dp(12) if u.mode == "phone" { panel = compactCard spacing = unit.Dp(8) } u.ensureNavClickables() 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.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 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 { lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage()) lbl.Color = mutedColor return lbl.Layout(gtx) } 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 { 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 { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.showEntries, "Entries") btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} btn.TextSize = unit.Sp(11) btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.showRecycle, "Recycle Bin") btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} btn.TextSize = unit.Sp(11) btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.showAPITokens, "API Tokens") btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} btn.TextSize = unit.Sp(11) btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.showAPIAudit, "API Audit") btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} btn.TextSize = unit.Sp(11) btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} return btn.Layout(gtx) }), ) } 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 := unit.Dp(12) titleSize := unit.Sp(18) metaSize := unit.Sp(14) urlSize := unit.Sp(13) if u.mode == "phone" { inset = unit.Dp(10) titleSize = unit.Sp(16) metaSize = unit.Sp(13) urlSize = unit.Sp(12) } row := func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(inset).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, titleSize, item.Title) 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, metaSize, item.Username) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, urlSize, item.URL) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(u.search.Text()) == "" { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / ")) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { w := gtx.Constraints.Max.X if w < 1 { w = 1 } paint.FillShape(gtx.Ops, color.NRGBA{R: 232, G: 227, B: 219, A: 255}, clip.Rect{Max: image.Pt(w, 1)}.Op()) return layout.Dimensions{Size: image.Pt(w, 1)} }), ) }) } if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) { 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 } fillColor := selectedColor edgeColor := selectedEdge if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID { fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255} edgeColor = accentColor } paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op()) paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return row(gtx) }), ) } return layout.Background{}.Layout(gtx, fill(panelColor), 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(18)) gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(18)) 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(84)) handleH := gtx.Dp(unit.Dp(4)) 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+1), Max: image.Pt(gtx.Constraints.Min.X, y+2)}.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 { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Security") }), 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() { 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(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(8) if u.mode == "phone" { titleSize = unit.Sp(18) titlePad = unit.Dp(6) sectionGap = unit.Dp(6) } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, titleSize, item.Title) lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: titlePad}.Layout, detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / ")), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "Username", item.Username), layout.Spacer{Height: sectionGap}.Layout, u.passwordLine("Password", password), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "URL", item.URL), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "Tags", strings.Join(item.Tags, ", ")), layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }), ) } return layout.Flex{}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.copyUser, "Copy User") return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.copyPass, "Copy Password") return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.copyURL, "Copy URL") return btn.Layout(gtx) }), ) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, item.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, u.historyPanel, layout.Spacer{Height: unit.Dp(12)}.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") 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} } 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 { lbl := material.Label(u.theme, unit.Sp(14), banner.Message) lbl.Color = fg return lbl.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 { 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 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) 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 { lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin") lbl.Color = mutedColor return lbl.Layout(gtx) } u.syncCurrentPath() displayPath := u.displayPath() crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) if u.state.Section == appstate.SectionTemplates { crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) } 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) { if index == 0 { root := u.hiddenVaultRoot() if root == "" { u.setCurrentPath(nil) } else { u.setCurrentPath([]string{root}) } } else { nextPath := crumbs[1 : index+1] 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.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(11) 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 return layout.UniformInset(unit.Dp(6)).Layout(gtx, lbl.Layout) })) } } return children }()...) } 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)) } if len(groups) == 0 { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { children := []layout.FlexChild{ 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(layout.Spacer{Height: unit.Dp(6)}.Layout), } children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { const maxGroupListHeight = 160 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) }) }) })) return children }()...) } func detailLine(th *material.Theme, label, value 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(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, unit.Sp(16), value) return lbl.Layout(gtx) }), ) } } func (u *ui) passwordLine(label, value 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(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, unit.Sp(16), 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 outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { appearance := fieldFocusAppearance(gtx.Metric, 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 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(false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) 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) 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.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 }