Files
keepassgo/internal/appui/frame.go
T
2026-04-10 16:08:08 -07:00

1379 lines
39 KiB
Go

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)
u.frameInsetPx = gtx.Dp(unit.Dp(16))
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
}
u.maybeLogHeaderMenuToggle("sync", u.syncMenuOpen)
}
for u.toggleMainMenu.Clicked(gtx) {
u.mainMenuOpen = !u.mainMenuOpen
if u.mainMenuOpen {
u.syncMenuOpen = false
}
u.maybeLogHeaderMenuToggle("main", u.mainMenuOpen)
}
for u.openAdvancedSync.Clicked(gtx) {
u.openAdvancedSyncDialog()
}
for u.openSecuritySettings.Clicked(gtx) {
u.loadSecuritySettingsFromSession()
u.loadSettingsFormFromPreferences()
u.loadSettingsDraft()
u.mainMenuOpen = false
u.securityDialogOpen = true
}
for u.openRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = true
}
for u.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)
}