package appui import ( "errors" "fmt" "path/filepath" "slices" "strings" "time" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/session" ) func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": return uiBanner{ Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage), Detail: u.loadingDetailMessage(), } case strings.TrimSpace(u.state.ErrorMessage) != "": return uiBanner{ Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage), Dismissable: true, } default: return uiBanner{} } } func (u *ui) statusToastSurface() uiBanner { if strings.TrimSpace(u.state.StatusMessage) == "" { return uiBanner{} } if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return uiBanner{} } return uiBanner{ Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage), } } func (u *ui) autofillStatusSurface() uiAutofillStatus { if u.autofillNoticePreference == autofillNoticeSuppressed { return uiAutofillStatus{} } if request, ok := u.pendingAutofillApproval(); ok { detail := approvalResourceText(request) if strings.TrimSpace(detail) == "" { detail = "Review the request to allow or deny this fill attempt." } return uiAutofillStatus{ Kind: autofillStatusAwaitingApproval, Title: "Autofill approval needed", Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.", Detail: detail, } } if u.auditLog == nil { return uiAutofillStatus{} } if u.autofillNoticePreference == autofillNoticeApprovals { return uiAutofillStatus{} } for _, event := range u.auditLog.Events() { if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { return status } } return uiAutofillStatus{} } func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) { for _, request := range u.state.PendingApprovals() { if isAutofillOperation(request.Operation) { return request, true } } return apiapproval.Request{}, false } func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) { if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL { return uiAutofillStatus{}, false } requester := formatAutofillRequester(event.ClientName, event.TokenName) switch event.Type { case apiaudit.EventAutofillFound: return uiAutofillStatus{ Kind: autofillStatusFound, Title: "Autofill match ready", Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."), Detail: autofillEventDetail(event), }, true case apiaudit.EventAutofillAmbiguous: return uiAutofillStatus{ Kind: autofillStatusAmbiguous, Title: "Autofill needs a narrower match", Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."), Detail: autofillEventDetail(event), }, true case apiaudit.EventAutofillBlocked: return uiAutofillStatus{ Kind: autofillStatusBlocked, Title: "Autofill is blocked", Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."), Detail: autofillEventDetail(event), }, true case apiaudit.EventApprovalAllowed: if !isAutofillOperation(event.Operation) { return uiAutofillStatus{}, false } return uiAutofillStatus{ Kind: autofillStatusFound, Title: "Autofill approved", Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."), Detail: autofillEventDetail(event), }, true case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut: if !isAutofillOperation(event.Operation) { return uiAutofillStatus{}, false } return uiAutofillStatus{ Kind: autofillStatusBlocked, Title: "Autofill was not allowed", Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)), Detail: autofillEventDetail(event), }, true default: return uiAutofillStatus{}, false } } func autofillEventDetail(event apiaudit.Event) string { return strings.TrimSpace(resourceDetailText(event.Resource)) } func resourceDetailText(resource apitokens.Resource) string { switch resource.Kind { case apitokens.ResourceEntry: if entryID := strings.TrimSpace(resource.EntryID); entryID != "" { return "Entry ID: " + entryID } case apitokens.ResourceGroup: if len(resource.Path) > 0 { return "Group: " + strings.Join(resource.Path, " / ") } } return "" } func formatAutofillRequester(clientName, tokenName string) string { switch { case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "": return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")" case strings.TrimSpace(clientName) != "": return strings.TrimSpace(clientName) case strings.TrimSpace(tokenName) != "": return strings.TrimSpace(tokenName) default: return "A trusted client" } } func defaultAutofillMessage(value, fallback string) string { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } return fallback } func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string { switch eventType { case apiaudit.EventApprovalDenied: return requester + " was denied for this fill request." case apiaudit.EventApprovalCanceled: return requester + " canceled this fill request." case apiaudit.EventApprovalTimedOut: return requester + " timed out while waiting for approval." default: return requester + " could not fill this target." } } func isAutofillOperation(operation apitokens.Operation) bool { switch operation { case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL: return true default: return false } } func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { if !u.shouldShowLifecycleSetup() { if banner.Dismissable { return "", "Dismiss" } return "", "" } switch banner.Kind { case bannerLoading: if strings.HasPrefix(u.loadingActionLabel, "open ") { return "Cancel", "" } case bannerError: if u.canRetryLifecycleOpen() { return "Retry", "Dismiss" } if banner.Dismissable { return "", "Dismiss" } } return "", "" } func (u *ui) loadingDetailMessage() string { if !u.shouldShowLifecycleSetup() { return "" } if u.lifecycleMode == "remote" { baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) switch { case baseURL != "" && path != "": return fmt.Sprintf( "Target: %s (%s)", friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), path, ) case baseURL != "": return "Target: " + baseURL default: return "Preparing remote vault access" } } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { return "Preparing local vault access" } return "Target: " + path } func (u *ui) currentVaultSummary() vaultSummary { status, ok := u.state.Session.(sessionStatus) if !ok || !status.HasVault() { return vaultSummary{} } if status.IsRemote() { baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) summary := vaultSummary{ Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), Detail: baseURL, } if strings.TrimSpace(summary.Title) == "" { summary.Title = "Remote vault" } summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path)) return summary } path := strings.TrimSpace(u.vaultPath.Text()) summary := vaultSummary{ Title: friendlyRecentVaultLabel(path), Detail: path, } if strings.TrimSpace(summary.Title) == "" { summary.Title = "Local vault" } summary.Context = u.vaultResumeContext(u.recentVaultGroup(path)) return summary } func (u *ui) vaultResumeContext(path []string) string { if len(path) == 0 { return "" } displayPath := append([]string(nil), path...) if len(displayPath) == 0 { return "" } return "Resume in: " + strings.Join(displayPath, " / ") } func compactPathDirectorySummary(path string) string { cleaned := filepath.Clean(strings.TrimSpace(path)) if cleaned == "." || cleaned == "" { return "" } dir := filepath.Dir(cleaned) if dir == "." || dir == cleaned { return "" } if dir == string(filepath.Separator) { return dir } parts := strings.Split(filepath.ToSlash(dir), "/") filtered := parts[:0] for _, part := range parts { if strings.TrimSpace(part) != "" { filtered = append(filtered, part) } } parts = filtered if len(parts) <= 2 { return filepath.ToSlash(dir) } return parts[0] + "/.../" + parts[len(parts)-1] } func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { if !u.requestMasterPassFocus { return } gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) gtx.Execute(op.InvalidateCmd{}) u.requestMasterPassFocus = false } func (u *ui) sessionSurface() uiSurface { if u.state.Session == nil { return uiSurface{} } if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { return uiSurface{ Title: "Vault locked", Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", Locked: true, } } return uiSurface{} } func (u *ui) hasOpenVault() bool { status, ok := u.state.Session.(sessionStatus) if ok { return status.HasVault() } _, err := u.state.Session.Current() return err == nil } func (u *ui) isVaultLocked() bool { status, ok := u.state.Session.(sessionStatus) if ok { return status.IsLocked() } _, err := u.state.Session.Current() return errors.Is(err, session.ErrLocked) } func (u *ui) shouldShowLifecycleSetup() bool { return !u.hasOpenVault() } func (u *ui) lifecycleBusy() bool { return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" } func (u *ui) updateViewportLayoutMode(gtx layout.Context) { u.viewportMeasured = true u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) } func (u *ui) usesCompactViewport() bool { if u.viewportMeasured { return u.compactViewport } return u.mode == "phone" } func (u *ui) shouldUseLockedSinglePane() bool { return u.isVaultLocked() && !u.shouldShowLifecycleSetup() } func (u *ui) shouldShowDesktopWorkingHeader() bool { return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() } func (u *ui) shouldUseCompactPhoneDetailPane() bool { if !u.usesCompactViewport() { return false } if u.isVaultLocked() || u.editingEntry { return false } _, ok := u.selectedEntry() return !ok } func (u *ui) chooseExistingFileAction(target *widget.Editor) error { path, err := pickExistingFile() if err != nil { return err } target.SetText(path) return nil } func (u *ui) listEmptyMessage() string { return u.listEmptyState().Body } func (u *ui) listEmptyState() emptyState { if surface := u.sessionSurface(); surface.Locked { return emptyState{ Title: "Vault locked", Body: "Unlock the vault to browse entries and groups.", } } query := strings.TrimSpace(u.search.Text()) if query != "" { switch u.state.Section { case appstate.SectionAPITokens: return emptyState{ Title: "No matching API tokens", Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query), } case appstate.SectionAPIAudit: return emptyState{ Title: "No matching audit events", Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query), } case appstate.SectionTemplates: return emptyState{ Title: "No matching templates", Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), } case appstate.SectionRecycleBin: return emptyState{ Title: "No matching deleted entries", Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), } default: return emptyState{ Title: "No matching entries", Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), } } } switch u.state.Section { case appstate.SectionAPITokens: return emptyState{ Title: "No API tokens yet", Body: "Issue a token to grant scoped gRPC access to an external tool.", } case appstate.SectionAPIAudit: return emptyState{ Title: "No API audit events yet", Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", } case appstate.SectionAbout: return emptyState{ Title: "About KeePassGO", Body: "Product details, compatibility notes, and platform targets appear in the detail pane.", } case appstate.SectionTemplates: return emptyState{ Title: "Templates unavailable", Body: "Templates are not available in this build.", } case appstate.SectionRecycleBin: return emptyState{ Title: "Recycle Bin is empty", Body: "Deleted entries will appear here until restored.", } default: if len(u.displayPath()) > 0 { return emptyState{ Title: "This group is empty", Body: "Add an entry here, search below this point, or open a subgroup.", } } return emptyState{ Title: "No entries yet", Body: "Create or open a vault, then add an entry to get started.", } } } func (u *ui) detailPlaceholderMessage() string { if surface := u.sessionSurface(); surface.Locked { return "Unlock the vault to inspect entries, attachments, and history." } if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { return "Complete the form to create a new item or update the current selection." } switch u.state.Section { case appstate.SectionAPITokens: return "Select an API token, issue a new one, or search to narrow the list." case appstate.SectionAPIAudit: return "Select an audit event to inspect it, or use Search audit log or the quick filters above." case appstate.SectionAbout: return "Review the product overview, platform support, and compatibility goals." case appstate.SectionTemplates: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: return "Select a deleted entry to review or restore it." default: if strings.TrimSpace(u.search.Text()) != "" { return "Select a matching entry from the filtered list or clear the search." } if len(u.displayPath()) == 0 { return "Select an entry from the vault root or open a group." } return "Select an entry or start a new one." } } func (u *ui) ensureNavClickables() { u.syncCurrentPath() if len(u.breadcrumbs) < len(u.currentPath)+1 { u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) } } func (u *ui) syncPhoneGroupBrowser(path []string) { if !u.usesCompactViewport() { return } u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 } func (u *ui) setCurrentPath(path []string) { u.currentPath = append([]string(nil), path...) u.state.NavigateToPath(path) u.syncedPath = append([]string(nil), path...) u.syncPhoneGroupBrowser(path) u.noteCurrentVaultPath() u.clearDeleteGroupConfirmation() } func (u *ui) syncCurrentPath() { switch { case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): u.currentPath = append([]string(nil), u.state.CurrentPath...) case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): u.state.CurrentPath = append([]string(nil), u.currentPath...) case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): u.state.CurrentPath = append([]string(nil), u.currentPath...) } u.syncedPath = append([]string(nil), u.currentPath...) u.noteCurrentVaultPath() if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) { u.clearDeleteGroupConfirmation() } } func (u *ui) noteCurrentVaultPath() { status, ok := u.state.Session.(sessionStatus) if !ok || status.IsLocked() { return } if status.IsRemote() { u.noteCurrentRemotePath() return } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { return } if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) u.saveRecentVaults() } func (u *ui) layout(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) u.phoneSyncMenuVisible = false u.phoneMainMenuVisible = false u.syncHostedAPI() u.filter() u.processShortcuts(gtx) u.handleLifecycleClicks(gtx) u.handleHeaderAndDialogClicks(gtx) u.handleSettingsClicks(gtx) u.handleSectionAndSyncClicks(gtx) u.handleApprovalAndAPIClicks(gtx) u.handleSelectionClicks(gtx) u.handleVaultAndEntryClicks(gtx) u.handleGroupClicks(gtx) u.handleInputUpdates(gtx) u.updateViewportLayoutMode(gtx) 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, u.mainFrame) }) }), layout.Stacked(u.syncDialogOverlay), layout.Stacked(u.securityDialogOverlay), layout.Stacked(u.remotePrefsDialogOverlay), layout.Stacked(u.approvalDialogOverlay), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return u.phoneHeaderMenus(gtx) }), layout.Stacked(u.statusToast), ) } func (u *ui) handleLifecycleClicks(gtx layout.Context) { for u.createVault.Clicked(gtx) { u.runAction("create vault", u.createVaultAction) } for u.openVault.Clicked(gtx) { u.startOpenVaultAction() } for u.lifecycleRemoteSyncAction.Clicked(gtx) { if !u.lifecycleBusy() { u.beginLifecycleRemoteSyncOpen() } } for u.unlockVault.Clicked(gtx) { u.startUnlockAction() } for u.cancelLifecycleProgress.Clicked(gtx) { u.cancelLifecycleBusyState() } for u.retryLifecycleOpen.Clicked(gtx) { u.state.ErrorMessage = "" u.retryLastLifecycleOpen() } for u.toggleLifecycleAdvanced.Clicked(gtx) { if !u.lifecycleBusy() { u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden u.saveUIPreferences() } } } func (u *ui) handleHeaderAndDialogClicks(gtx layout.Context) { u.handleHeaderActionClicks(gtx) u.handleDialogControlClicks(gtx) u.handleBannerClicks(gtx) } func (u *ui) handleHeaderActionClicks(gtx layout.Context) { for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) } for u.saveAsVault.Clicked(gtx) { u.runAction("save-as vault", u.saveAsAction) } for u.openRemote.Clicked(gtx) { u.startOpenRemoteAction() } for u.changeMasterKey.Clicked(gtx) { u.runAction("change master key", u.changeMasterKeyAction) } for u.synchronizeVault.Clicked(gtx) { u.runAction("synchronize vault", u.synchronizeAction) } for u.toggleSyncMenu.Clicked(gtx) { u.syncMenuOpen = !u.syncMenuOpen if u.syncMenuOpen { u.mainMenuOpen = false } } for u.toggleMainMenu.Clicked(gtx) { u.mainMenuOpen = !u.mainMenuOpen if u.mainMenuOpen { u.syncMenuOpen = false } } for u.openAdvancedSync.Clicked(gtx) { u.openAdvancedSyncDialog() } for u.openSecuritySettings.Clicked(gtx) { u.loadSecuritySettingsFromSession() u.loadSettingsFormFromPreferences() u.loadSettingsDraft() u.mainMenuOpen = false u.securityDialogOpen = true } for u.openRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = true } for u.lockVault.Clicked(gtx) { u.runAction("lock vault", u.lockAction) } } func (u *ui) handleDialogControlClicks(gtx layout.Context) { for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false } for u.closeSecuritySettings.Clicked(gtx) { u.securityDialogOpen = false } for u.closeRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = false } for u.runAdvancedSync.Clicked(gtx) { u.runAction("advanced synchronize vault", u.advancedSyncAction) } for u.saveSecuritySettings.Clicked(gtx) { u.runAction("save settings", u.saveSecuritySettingsAction) } } func (u *ui) handleBannerClicks(gtx layout.Context) { for u.dismissBanner.Clicked(gtx) { u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} } } func (u *ui) handleSettingsClicks(gtx layout.Context) { u.handleStatusPreferenceClicks(gtx) u.handleAutofillPreferenceClicks(gtx) u.handleAccessibilityClicks(gtx) u.handleSettingsSyncDefaultClicks(gtx) } func (u *ui) handleStatusPreferenceClicks(gtx layout.Context) { for u.setStatusBannerShort.Clicked(gtx) { u.setStatusBannerTTL(2 * time.Second) } for u.setStatusBannerStandard.Clicked(gtx) { u.setStatusBannerTTL(statusBannerDuration) } for u.setStatusBannerLong.Clicked(gtx) { u.setStatusBannerTTL(statusBannerLong) } } func (u *ui) handleAutofillPreferenceClicks(gtx layout.Context) { for u.showAllAutofillNotices.Clicked(gtx) { u.setAutofillNoticePreference(autofillNoticeAll) } for u.showApprovalAutofillOnly.Clicked(gtx) { u.setAutofillNoticePreference(autofillNoticeApprovals) } for u.hideAutofillNotices.Clicked(gtx) { u.setAutofillNoticePreference(autofillNoticeSuppressed) } for u.showAutofillApprovalAsk.Clicked(gtx) { u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk u.saveUIPreferences() } for u.showAutofillApprovalAllow.Clicked(gtx) { u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow u.saveUIPreferences() } for u.showAutofillApprovalBlock.Clicked(gtx) { u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock u.saveUIPreferences() } } func (u *ui) handleAccessibilityClicks(gtx layout.Context) { for u.settingsDensityDense.Clicked(gtx) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense _ = u.applySecuritySettingsLive() } for u.settingsDensityComfortable.Clicked(gtx) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityComfortable _ = u.applySecuritySettingsLive() } for u.settingsContrastStandard.Clicked(gtx) { u.settingsDraft.Accessibility.Contrast = contrastStandard _ = u.applySecuritySettingsLive() } for u.settingsContrastHigh.Clicked(gtx) { u.settingsDraft.Accessibility.Contrast = contrastHigh _ = u.applySecuritySettingsLive() } for u.settingsReducedMotionOff.Clicked(gtx) { u.settingsDraft.Accessibility.ReducedMotion = false _ = u.applySecuritySettingsLive() } for u.settingsReducedMotionOn.Clicked(gtx) { u.settingsDraft.Accessibility.ReducedMotion = true _ = u.applySecuritySettingsLive() } for u.settingsKeyboardFocusStandard.Clicked(gtx) { u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusStandard _ = u.applySecuritySettingsLive() } for u.settingsKeyboardFocusProminent.Clicked(gtx) { u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent _ = u.applySecuritySettingsLive() } } func (u *ui) handleSettingsSyncDefaultClicks(gtx layout.Context) { for u.showSettingsSyncLocal.Clicked(gtx) { u.settingsDraft.Sync.SourceDefault = syncSourceLocal _ = u.applySecuritySettingsLive() } for u.showSettingsSyncRemote.Clicked(gtx) { u.settingsDraft.Sync.SourceDefault = syncSourceRemote _ = u.applySecuritySettingsLive() } for u.showSettingsSyncPull.Clicked(gtx) { u.settingsDraft.Sync.DirectionDefault = syncDirectionPull _ = u.applySecuritySettingsLive() } for u.showSettingsSyncPush.Clicked(gtx) { u.settingsDraft.Sync.DirectionDefault = syncDirectionPush _ = u.applySecuritySettingsLive() } } func (u *ui) handleSectionAndSyncClicks(gtx layout.Context) { u.handleSectionClicks(gtx) u.handleLifecycleModeClicks(gtx) u.handleSyncChoiceClicks(gtx) u.handleRemoteBindingClicks(gtx) } func (u *ui) handleSectionClicks(gtx layout.Context) { 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.showAbout.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showAboutSection() } } func (u *ui) handleLifecycleModeClicks(gtx layout.Context) { for u.showLocalLifecycle.Clicked(gtx) { if !u.lifecycleBusy() { u.lifecycleMode = "local" u.requestMasterPassFocus = true } } for u.showRemoteLifecycle.Clicked(gtx) { if !u.lifecycleBusy() { u.lifecycleMode = "remote" u.selectedRemoteConnection = false u.requestMasterPassFocus = true } } } func (u *ui) handleSyncChoiceClicks(gtx layout.Context) { 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 } } func (u *ui) handleRemoteBindingClicks(gtx layout.Context) { for u.useSavedAdvancedSyncRemote.Clicked(gtx) { u.openRemoteSyncSetupDialog() } for u.openSelectedVaultRemote.Clicked(gtx) { if !u.lifecycleBusy() { u.startOpenRemoteAction() } } for u.saveCurrentRemoteBinding.Clicked(gtx) { u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) } for u.removeSelectedRemoteBinding.Clicked(gtx) { u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction) } for u.shareCurrentVault.Clicked(gtx) { u.runAction("share vault", u.shareCurrentVaultAction) } } func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { u.handleApprovalClicks(gtx) u.handleAPITokenClicks(gtx) u.handleAPIPolicyClicks(gtx) } func (u *ui) handleApprovalClicks(gtx layout.Context) { 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 }) } } func (u *ui) handleAPITokenClicks(gtx layout.Context) { 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.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) }) } } func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { for u.addAPIPolicyRule.Clicked(gtx) { u.runAction("add API policy rule", u.addAPIPolicyRuleAction) } for u.useCurrentGroupForPolicy.Clicked(gtx) { u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction) } for u.useSelectedEntryForPolicy.Clicked(gtx) { u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction) } for u.clearAPIPolicyTarget.Clicked(gtx) { u.runAction("clear API policy target", u.clearAPIPolicyTargetAction) } 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) }) } } } func (u *ui) handleSelectionClicks(gtx layout.Context) { u.handleFileSelectionClicks(gtx) u.handleRecentSelectionClicks(gtx) u.handleRemoteSelectionClicks(gtx) u.handleClearSelectionClicks(gtx) } func (u *ui) handleFileSelectionClicks(gtx layout.Context) { for u.pickVaultPath.Clicked(gtx) { if !u.lifecycleBusy() { u.startChooseVaultPathAction() } } for u.importSharedVault.Clicked(gtx) { if !u.lifecycleBusy() { u.startImportSharedVaultAction() } } for u.pickKeyFile.Clicked(gtx) { if !u.lifecycleBusy() { u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } } for u.pickSyncLocalPath.Clicked(gtx) { u.startChooseSyncLocalSourceAction() } } func (u *ui) handleRecentSelectionClicks(gtx layout.Context) { for i := range u.recentVaultClicks { for u.recentVaultClicks[i].Clicked(gtx) { if !u.lifecycleBusy() && i < len(u.recentVaults) { u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[i]) u.requestMasterPassFocus = true } } } for i := range u.recentRemoteClicks { for u.recentRemoteClicks[i].Clicked(gtx) { if !u.lifecycleBusy() && i < len(u.recentRemotes) { u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[i]) u.requestMasterPassFocus = true } } } } func (u *ui) handleRemoteSelectionClicks(gtx layout.Context) { for i := range u.vaultRemoteProfileClicks { for u.vaultRemoteProfileClicks[i].Clicked(gtx) { profiles := u.availableRemoteProfiles() if i < len(profiles) { u.selectVaultRemoteProfile(profiles[i].ID) } } } for i := range u.vaultRemoteCredentialClicks { for u.vaultRemoteCredentialClicks[i].Clicked(gtx) { entries := u.availableRemoteCredentialEntries() if i < len(entries) { u.selectVaultRemoteCredentialEntry(entries[i].ID) } } } for i := range u.syncRemoteCredentialClicks { for u.syncRemoteCredentialClicks[i].Clicked(gtx) { entries := u.matchingAdvancedSyncRemoteCredentialEntries() if i < len(entries) { u.applyAdvancedSyncRemoteCredentialEntry(entries[i]) } } } } func (u *ui) handleClearSelectionClicks(gtx layout.Context) { for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue } if u.shouldUseLockedSinglePane() { u.switchToLifecycleSelection("local") continue } u.vaultPath.SetText("") u.state.ErrorMessage = "" u.state.StatusMessage = "" u.requestMasterPassFocus = true } for u.clearRemoteSelection.Clicked(gtx) { if u.lifecycleBusy() { continue } if u.shouldUseLockedSinglePane() { u.switchToLifecycleSelection("remote") continue } u.selectedRemoteConnection = false u.remoteBaseURL.SetText("") u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") u.state.ErrorMessage = "" u.state.StatusMessage = "" u.requestMasterPassFocus = true } } func (u *ui) handleVaultAndEntryClicks(gtx layout.Context) { u.handleEntryEditorClicks(gtx) u.handleEntryMutationClicks(gtx) u.handleAttachmentAndCopyClicks(gtx) } func (u *ui) handleEntryEditorClicks(gtx layout.Context) { for u.editEntry.Clicked(gtx) { u.editingEntry = true u.loadSelectedEntryIntoEditor() } for u.cancelEdit.Clicked(gtx) { u.editingEntry = false u.loadSelectedEntryIntoEditor() } for u.addEntry.Clicked(gtx) { u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.editingEntry = true } } func (u *ui) handleEntryMutationClicks(gtx layout.Context) { 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) } } func (u *ui) handleAttachmentAndCopyClicks(gtx layout.Context) { 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) } } func (u *ui) handleGroupClicks(gtx layout.Context) { for u.createGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("create group", u.createGroupAction) } for u.moveGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("move group", u.moveCurrentGroupAction) u.currentPath = append([]string(nil), u.state.CurrentPath...) u.syncedPath = append([]string(nil), u.state.CurrentPath...) u.filter() } for u.toggleGroupControls.Clicked(gtx) { u.groupControlsHidden = !u.groupControlsHidden u.saveUIPreferences() } for u.toggleHistory.Clicked(gtx) { u.historyHidden = !u.historyHidden u.saveUIPreferences() } for u.renameGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("rename group", u.renameGroupAction) } for u.deleteGroup.Clicked(gtx) { u.armDeleteCurrentGroupAction() } for u.confirmDeleteGroup.Clicked(gtx) { u.runAction("delete group", u.deleteCurrentGroupAction) u.clearDeleteGroupConfirmation() } for u.cancelDeleteGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} } } func (u *ui) handleInputUpdates(gtx layout.Context) { if u.securityDialogOpen { if _, changed := u.securityCipher.Update(gtx); changed { _ = u.applySecuritySettingsLive() } if _, changed := u.securityKDF.Update(gtx); changed { _ = u.applySecuritySettingsLive() } if _, changed := u.autofillBrowserAllowlist.Update(gtx); changed { u.saveUIPreferences() } if _, changed := u.autofillAppAllowlist.Update(gtx); changed { u.saveUIPreferences() } if _, changed := u.autofillPackageRules.Update(gtx); changed { u.saveUIPreferences() } } 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() } } func (u *ui) mainFrame(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.header), layout.Rigid(u.bannerRow), layout.Rigid(u.autofillStatusRow), layout.Flexed(1, u.primaryContent), ) } func (u *ui) bannerRow(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), ) } func (u *ui) autofillStatusRow(gtx layout.Context) layout.Dimensions { if u.bannerSurface().Kind != bannerNone || u.autofillStatusSurface().Kind == autofillStatusNone { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(u.autofillStatusCard), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), ) } func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { switch { case u.shouldShowLifecycleSetup(): return u.lifecycleScreen(gtx) case u.shouldUseLockedSinglePane(): return u.detailPanel(gtx) case u.usesCompactViewport(): return u.compactPrimaryContent(gtx) default: return u.widePrimaryContent(gtx) } } func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) if min := gtx.Dp(unit.Dp(180)); listHeight < min { listHeight = min } if max := gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)); listHeight > max { listHeight = max } 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), u.compactDetailFlexChild(), ) } func (u *ui) compactDetailFlexChild() layout.FlexChild { if u.shouldUseCompactPhoneDetailPane() { return layout.Rigid(u.detailPanel) } return layout.Flexed(1, u.detailPanel) } func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { 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), ) } func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { if !u.syncDialogOpen { return layout.Dimensions{} } return u.syncDialog(gtx) } func (u *ui) securityDialogOverlay(gtx layout.Context) layout.Dimensions { if !u.securityDialogOpen { return layout.Dimensions{} } return u.securityDialog(gtx) } func (u *ui) remotePrefsDialogOverlay(gtx layout.Context) layout.Dimensions { if !u.remotePrefsDialogOpen { return layout.Dimensions{} } return u.remotePrefsDialog(gtx) } func (u *ui) approvalDialogOverlay(gtx layout.Context) layout.Dimensions { if _, ok := u.pendingApproval(); !ok { return layout.Dimensions{} } return u.approvalDialog(gtx) }