Files
keepassgo/main.go
T
2026-04-07 21:41:52 -07:00

7716 lines
255 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"image"
"image/color"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"gioui.org/app"
"gioui.org/gesture"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/api"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/appstate"
keepassassets "git.julianfamily.org/keepassgo/assets"
"git.julianfamily.org/keepassgo/autofillcache"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"golang.org/x/exp/shiny/materialdesign/icons"
)
var appVersion = "dev"
func currentAppVersion() string {
if strings.TrimSpace(appVersion) == "" {
return "dev"
}
return strings.TrimSpace(appVersion)
}
type entry = vault.Entry
const (
productName = "KeePassGO"
)
const (
maxAttachmentBytes = 10 << 20
statusBannerDuration = 2600 * time.Millisecond
statusBannerLong = 6 * time.Second
autofillStatusTTL = 12 * time.Second
)
type autofillNoticeMode string
const (
autofillNoticeAll autofillNoticeMode = "all"
autofillNoticeApprovals autofillNoticeMode = "approvals"
autofillNoticeSuppressed autofillNoticeMode = "suppressed"
)
type listPanelTopSection string
const (
listPanelTopSearch listPanelTopSection = "search"
listPanelTopNavigation listPanelTopSection = "navigation"
listPanelTopPath listPanelTopSection = "path"
listPanelTopGroup listPanelTopSection = "group"
listPanelTopGroupTools listPanelTopSection = "group_tools"
listPanelTopPrimary listPanelTopSection = "primary"
)
type bannerKind string
const (
bannerNone bannerKind = ""
bannerLoading bannerKind = "loading"
bannerError bannerKind = "error"
bannerStatus bannerKind = "status"
)
type uiBanner struct {
Kind bannerKind
Message string
Detail string
Dismissable bool
}
type autofillStatusKind string
const (
autofillStatusNone autofillStatusKind = ""
autofillStatusFound autofillStatusKind = "found"
autofillStatusAmbiguous autofillStatusKind = "ambiguous"
autofillStatusBlocked autofillStatusKind = "blocked"
autofillStatusAwaitingApproval autofillStatusKind = "awaiting_approval"
)
type uiAutofillStatus struct {
Kind autofillStatusKind
Title string
Message string
Detail string
}
type uiSurface struct {
Title string
Message string
Locked bool
}
type lifecycleOpenIntent string
const (
lifecycleOpenIntentNone lifecycleOpenIntent = ""
lifecycleOpenIntentRemoteSyncSetup lifecycleOpenIntent = "remote_sync_setup"
lifecycleOpenIntentRemoteSyncSettings lifecycleOpenIntent = "remote_sync_settings"
)
type emptyState struct {
Title string
Body string
}
type vaultSummary struct {
Title string
Detail string
Context string
}
type sessionStatus interface {
HasVault() bool
IsLocked() bool
IsRemote() bool
}
type attachmentItem struct {
Name string
Size int
}
type statePaths struct {
DefaultSaveAsPath string
RecentVaultsPath string
RecentRemotesPath string
SettingsPath string
UIPreferencesPath string
AutofillCachePath string
PendingSharedVaultPath string
PendingSharedVaultNamePath 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"`
LocalVaultPath string `json:"localVaultPath,omitempty"`
RemoteProfileID string `json:"remoteProfileId,omitempty"`
CredentialEntryID string `json:"credentialEntryId,omitempty"`
SyncMode string `json:"syncMode,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
LastGroup []string `json:"lastGroup,omitempty"`
UsedAt string `json:"usedAt,omitempty"`
NeedsMigration bool `json:"-"`
}
type uiPreferences struct {
GroupControlsHidden bool `json:"groupControlsHidden"`
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
HistoryHidden bool `json:"historyHidden"`
DenseLayout bool `json:"denseLayout"`
StatusBannerMillis int `json:"statusBannerMillis,omitempty"`
AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"`
DisplayDensity string `json:"displayDensity,omitempty"`
Contrast string `json:"contrast,omitempty"`
ReducedMotion bool `json:"reducedMotion,omitempty"`
KeyboardFocus string `json:"keyboardFocus,omitempty"`
AutofillPrivacy autofillPrivacySettings `json:"autofillPrivacy,omitempty"`
}
type autofillPrivacySettings struct {
FirstFillApprovalMode string `json:"firstFillApprovalMode,omitempty"`
BrowserAllowlist []string `json:"browserAllowlist,omitempty"`
AppAllowlist []string `json:"appAllowlist,omitempty"`
PackageRules []string `json:"packageRules,omitempty"`
}
type autofillFirstFillApprovalMode string
const (
autofillFirstFillApprovalAsk autofillFirstFillApprovalMode = "ask"
autofillFirstFillApprovalAllow autofillFirstFillApprovalMode = "allow"
autofillFirstFillApprovalBlock autofillFirstFillApprovalMode = "block"
)
type entriesSectionState struct {
Path []string
SearchQuery string
SelectedEntryID string
Editing bool
}
type syncSourceMode string
const (
syncSourceLocal syncSourceMode = "local"
syncSourceRemote syncSourceMode = "remote"
)
type syncDirection string
const (
syncDirectionPull syncDirection = "pull"
syncDirectionPush syncDirection = "push"
)
type syncDialogPurpose string
const (
syncDialogPurposeAdvanced syncDialogPurpose = "advanced"
syncDialogPurposeRemoteSetup syncDialogPurpose = "remote-setup"
)
type ui struct {
mode string
theme *material.Theme
fileExplorer *explorer.Explorer
logoHorizontal paint.ImageOp
splashSquare paint.ImageOp
search widget.Editor
vaultPath widget.Editor
saveAsPath widget.Editor
remoteBaseURL widget.Editor
remotePath widget.Editor
remoteUsername widget.Editor
remotePassword widget.Editor
masterPassword widget.Editor
keyFilePath widget.Editor
apiTokenName widget.Editor
apiTokenClientName widget.Editor
apiTokenExpiresAt widget.Editor
apiPolicyOperation widget.Editor
apiPolicyPath widget.Editor
apiPolicyEntryID widget.Editor
securityCipher widget.Editor
securityKDF widget.Editor
entryID widget.Editor
entryTitle widget.Editor
entryUsername widget.Editor
entryPassword widget.Editor
entryURL widget.Editor
entryNotes widget.Editor
entryTags widget.Editor
entryPath widget.Editor
entryFields widget.Editor
customFieldKeys []widget.Editor
customFieldValues []widget.Editor
historyIndex widget.Editor
groupName widget.Editor
groupParentPath widget.Editor
passwordProfile widget.Editor
attachmentName widget.Editor
attachmentPath widget.Editor
exportAttachmentPath widget.Editor
autofillBrowserAllowlist widget.Editor
autofillAppAllowlist widget.Editor
autofillPackageRules widget.Editor
list widget.List
groupList widget.List
detailList widget.List
apiPolicyList widget.List
lifecycleList widget.List
syncDialogList widget.List
phonePanelList widget.List
securityDialogList widget.List
remotePrefsDialogList widget.List
recentVaultListState widget.List
recentRemoteListState widget.List
copyUser widget.Clickable
copyPass widget.Clickable
copyURL widget.Clickable
lockVault widget.Clickable
unlockVault widget.Clickable
createVault widget.Clickable
openVault widget.Clickable
lifecycleRemoteSyncAction widget.Clickable
saveVault widget.Clickable
saveAsVault widget.Clickable
openRemote widget.Clickable
changeMasterKey widget.Clickable
synchronizeVault widget.Clickable
toggleSyncMenu widget.Clickable
toggleMainMenu widget.Clickable
openAdvancedSync widget.Clickable
useSavedAdvancedSyncRemote widget.Clickable
openSelectedVaultRemote widget.Clickable
saveCurrentRemoteBinding widget.Clickable
removeSelectedRemoteBinding widget.Clickable
openSecuritySettings widget.Clickable
openRemotePrefsHelp widget.Clickable
closeAdvancedSync widget.Clickable
closeSecuritySettings widget.Clickable
closeRemotePrefsHelp widget.Clickable
runAdvancedSync widget.Clickable
saveSecuritySettings widget.Clickable
settingsDensityDense widget.Clickable
settingsDensityComfortable widget.Clickable
settingsContrastStandard widget.Clickable
settingsContrastHigh widget.Clickable
settingsReducedMotionOff widget.Clickable
settingsReducedMotionOn widget.Clickable
settingsKeyboardFocusStandard widget.Clickable
settingsKeyboardFocusProminent widget.Clickable
showSettingsSyncLocal widget.Clickable
showSettingsSyncRemote widget.Clickable
showSettingsSyncPull widget.Clickable
showSettingsSyncPush widget.Clickable
editEntry widget.Clickable
cancelEdit widget.Clickable
pickVaultPath widget.Clickable
importSharedVault widget.Clickable
shareCurrentVault widget.Clickable
pickKeyFile widget.Clickable
pickSyncLocalPath widget.Clickable
clearVaultSelection widget.Clickable
clearRemoteSelection widget.Clickable
dismissBanner widget.Clickable
addEntry widget.Clickable
saveEntry widget.Clickable
duplicateEntry widget.Clickable
deleteEntry widget.Clickable
restoreEntry widget.Clickable
saveTemplate widget.Clickable
deleteTemplate widget.Clickable
instantiateTemplate widget.Clickable
addAttachment widget.Clickable
replaceAttachment widget.Clickable
removeAttachment widget.Clickable
exportAttachment widget.Clickable
restoreHistory widget.Clickable
generatePassword widget.Clickable
goToRootGroup widget.Clickable
goToParentGroup widget.Clickable
createGroup widget.Clickable
moveGroup widget.Clickable
renameGroup widget.Clickable
deleteGroup widget.Clickable
confirmDeleteGroup widget.Clickable
cancelDeleteGroup widget.Clickable
addCustomField widget.Clickable
toggleGroupControls widget.Clickable
toggleLifecycleAdvanced widget.Clickable
toggleHistory widget.Clickable
togglePasswordInline widget.Clickable
toggleSyncPassword widget.Clickable
setStatusBannerShort widget.Clickable
setStatusBannerStandard widget.Clickable
setStatusBannerLong widget.Clickable
showAllAutofillNotices widget.Clickable
showApprovalAutofillOnly widget.Clickable
hideAutofillNotices widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
showAPITokens widget.Clickable
showAPIAudit widget.Clickable
showAbout widget.Clickable
showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable
showSyncLocal widget.Clickable
showSyncRemote widget.Clickable
showSyncPull widget.Clickable
showSyncPush widget.Clickable
showAutofillApprovalAsk widget.Clickable
showAutofillApprovalAllow widget.Clickable
showAutofillApprovalBlock widget.Clickable
allowApproval widget.Clickable
denyApproval widget.Clickable
cancelApproval widget.Clickable
cancelLifecycleProgress widget.Clickable
retryLifecycleOpen widget.Clickable
approvalPermanent widget.Bool
syncSetupAutomatic widget.Bool
apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool
apiTokenDisabled widget.Bool
settingsGroupControls widget.Bool
settingsLifecycleAdvanced widget.Bool
settingsHistory widget.Bool
settingsDenseLayout widget.Bool
entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable
apiAuditTokenFilters []widget.Clickable
apiAuditDecisionFilters []widget.Clickable
apiAuditOperationFilters []widget.Clickable
clearAPIAuditFilters widget.Clickable
historyClicks []widget.Clickable
attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable
groupClicks []widget.Clickable
recentVaultClicks []widget.Clickable
recentRemoteClicks []widget.Clickable
vaultRemoteProfileClicks []widget.Clickable
vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable
state appstate.State
visible []entry
currentPath []string
syncedPath []string
selectedHistoryIndex int
showPassword bool
generatedPasswordDraft bool
togglePassword widget.Clickable
copyAPITokenSecret widget.Clickable
issueAPIToken widget.Clickable
saveAPIToken widget.Clickable
rotateAPIToken widget.Clickable
disableAPIToken widget.Clickable
revokeAPIToken widget.Clickable
deleteAPIToken widget.Clickable
useCurrentGroupForPolicy widget.Clickable
useSelectedEntryForPolicy widget.Clickable
clearAPIPolicyTarget widget.Clickable
addAPIPolicyRule widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
splitStartY float32
phoneSpan int
phoneGroupBrowserExpanded bool
eyeIcon *widget.Icon
eyeOffIcon *widget.Icon
copyIcon *widget.Icon
expandMoreIcon *widget.Icon
expandLessIcon *widget.Icon
chevronRightIcon *widget.Icon
chevronDownIcon *widget.Icon
settingsIcon *widget.Icon
menuIcon *widget.Icon
clipboardWriter clipboard.Writer
vaultSharer vaultSharer
loadingMessage string
loadingActionLabel string
lifecycleMode string
syncSourceMode syncSourceMode
syncDirection syncDirection
syncLocalImportName string
syncLocalImportContent []byte
syncLocalPath widget.Editor
syncRemoteBaseURL widget.Editor
syncRemotePath widget.Editor
syncRemoteUsername widget.Editor
syncRemotePassword widget.Editor
selectedSyncRemoteCredentialEntryID string
syncDialogPurpose syncDialogPurpose
syncDialogOpen bool
syncMenuOpen bool
mainMenuOpen bool
selectedRemoteConnection bool
selectedVaultRemoteProfileID string
selectedVaultRemoteCredentialEntryID string
selectedVaultRemoteSyncMode appstate.SyncMode
securityDialogOpen bool
remotePrefsDialogOpen bool
showSyncPassword bool
keyboardFocus focusID
defaultSaveAsPath string
recentVaultsPath string
settingsPath string
uiPreferencesPath string
recentRemotesPath string
autofillCachePath string
pendingSharedVaultPath string
pendingSharedVaultNamePath string
editingEntry bool
syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection
groupControlsHidden bool
lifecycleAdvancedHidden bool
historyHidden bool
denseLayout bool
statusBannerTTL time.Duration
autofillNoticePreference autofillNoticeMode
autofillFirstFillApprovalMode autofillFirstFillApprovalMode
accessibilityPrefs accessibilityPreferences
settingsDraft settingsDraft
recentVaults []string
recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string
recentVaultUsedAt map[string]time.Time
entriesState entriesSectionState
deleteGroupPath []string
apiPolicyGroupScope bool
apiTokenSecret string
selectedAuditIndex int
statusExpiresAt time.Time
now func() time.Time
apiHost *api.Host
auditLog *apiaudit.Log
grpcAddress string
backgroundResults chan backgroundActionResult
backgroundActionSerial int
activeBackgroundAction int
lastLifecycleAction string
pendingLifecycleOpenIntent lifecycleOpenIntent
requestMasterPassFocus bool
invalidate func()
}
type backgroundActionResult struct {
label string
apply func() error
err error
id int
}
type vaultSharer interface {
ShareVault(path, title string) error
}
var (
bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255}
panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255}
accentColor = color.NRGBA{R: 28, G: 83, B: 63, A: 255}
mutedColor = color.NRGBA{R: 95, G: 93, B: 88, A: 255}
selectedColor = color.NRGBA{R: 221, G: 233, B: 226, A: 255}
selectedEdge = color.NRGBA{R: 73, G: 123, B: 100, A: 255}
)
const (
errVaultPathRequired = "vault path is required"
errSaveAsPathRequired = "save-as path is required"
)
func newUIWithModel(mode string, model vault.Model) *ui {
return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths(""))
}
func newUIWithSession(mode string, sess appstate.CurrentSession, paths ...statePaths) *ui {
selected := defaultStatePaths("")
if len(paths) > 0 {
selected = paths[0]
}
return newUIWithState(mode, sess, selected)
}
func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) *ui {
th := material.NewTheme()
th.Palette.Bg = bgColor
th.Palette.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255}
th.Palette.ContrastBg = accentColor
th.Palette.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
u := &ui{
mode: mode,
theme: th,
logoHorizontal: paint.NewImageOp(keepassassets.MustPNG("keepassgo-logo-horizontal.png")),
splashSquare: paint.NewImageOp(keepassassets.MustPNG("keepassgo-splash-square.png")),
search: widget.Editor{
SingleLine: true,
Submit: false,
},
vaultPath: widget.Editor{SingleLine: true, Submit: false},
saveAsPath: widget.Editor{SingleLine: true, Submit: false},
remoteBaseURL: widget.Editor{SingleLine: true, Submit: false},
remotePath: widget.Editor{SingleLine: true, Submit: false},
remoteUsername: widget.Editor{SingleLine: true, Submit: false},
remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword},
syncLocalPath: widget.Editor{SingleLine: true, Submit: false},
syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false},
syncRemotePath: widget.Editor{SingleLine: true, Submit: false},
syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false},
syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword},
masterPassword: widget.Editor{SingleLine: true, Submit: false, InputHint: key.HintPassword},
keyFilePath: widget.Editor{SingleLine: true, Submit: false},
apiTokenName: widget.Editor{SingleLine: true, Submit: false},
apiTokenClientName: widget.Editor{SingleLine: true, Submit: false},
apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false},
apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false},
apiPolicyPath: widget.Editor{SingleLine: true, Submit: false},
apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false},
securityCipher: widget.Editor{SingleLine: true, Submit: false},
securityKDF: widget.Editor{SingleLine: true, Submit: false},
entryID: widget.Editor{SingleLine: true, Submit: false},
entryTitle: widget.Editor{SingleLine: true, Submit: false},
entryUsername: widget.Editor{SingleLine: true, Submit: false},
entryPassword: widget.Editor{SingleLine: true, Submit: false},
entryURL: widget.Editor{SingleLine: true, Submit: false},
entryNotes: widget.Editor{SingleLine: false, Submit: false},
entryTags: widget.Editor{SingleLine: true, Submit: false},
entryPath: widget.Editor{SingleLine: true, Submit: false},
entryFields: widget.Editor{SingleLine: false, Submit: false},
historyIndex: widget.Editor{SingleLine: true, Submit: false},
groupName: widget.Editor{SingleLine: true, Submit: false},
groupParentPath: widget.Editor{SingleLine: true, Submit: false},
passwordProfile: widget.Editor{SingleLine: true, Submit: false},
attachmentName: widget.Editor{SingleLine: true, Submit: false},
attachmentPath: widget.Editor{SingleLine: true, Submit: false},
exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false},
autofillBrowserAllowlist: widget.Editor{SingleLine: false, Submit: false},
autofillAppAllowlist: widget.Editor{SingleLine: false, Submit: false},
autofillPackageRules: widget.Editor{SingleLine: false, Submit: false},
list: widget.List{
List: layout.List{Axis: layout.Vertical},
},
groupList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
detailList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
apiPolicyList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
lifecycleList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
syncDialogList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
phonePanelList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
securityDialogList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
remotePrefsDialogList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
recentVaultListState: widget.List{
List: layout.List{Axis: layout.Vertical},
},
recentRemoteListState: widget.List{
List: layout.List{Axis: layout.Vertical},
},
state: appstate.State{},
selectedHistoryIndex: -1,
selectedAuditIndex: -1,
lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath,
settingsPath: paths.SettingsPath,
uiPreferencesPath: paths.UIPreferencesPath,
recentRemotesPath: paths.RecentRemotesPath,
autofillCachePath: paths.AutofillCachePath,
pendingSharedVaultPath: paths.PendingSharedVaultPath,
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
lifecycleAdvancedHidden: true,
historyHidden: true,
statusBannerTTL: statusBannerDuration,
accessibilityPrefs: defaultAccessibilityPreferences(),
autofillFirstFillApprovalMode: autofillFirstFillApprovalAsk,
now: time.Now,
syncSourceMode: syncSourceLocal,
syncDirection: syncDirectionPull,
syncDefaultSourceMode: syncSourceLocal,
syncDefaultDirection: syncDirectionPull,
apiPolicyGroupScope: true,
autofillNoticePreference: autofillNoticeAll,
vaultSharer: newPlatformVaultSharer(runtime.GOOS),
backgroundResults: make(chan backgroundActionResult, 8),
phoneGroupBrowserExpanded: true,
}
if mode == "phone" {
u.groupControlsHidden = true
}
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
u.state.Session = sess
u.phoneSplit.Value = 0.46
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
u.chevronRightIcon, _ = widget.NewIcon(icons.NavigationChevronRight)
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings)
u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu)
u.passwordProfile.SetText("strong")
u.securityCipher.SetText(vault.CipherChaCha20)
u.securityKDF.SetText(vault.KDFArgon2)
u.keyboardFocus = focusSearch
u.setCustomFieldRows(nil)
u.loadRecentVaults()
u.loadRecentRemotes()
if u.hasLegacyRecentRemoteCredentialMigration() {
u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
}
u.consumePendingSharedVaultImport()
u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences()
u.loadSettings()
u.loadSettingsFormFromPreferences()
u.loadSettingsDraft()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.filter()
u.syncAutofillCache()
return u
}
func (u *ui) filter() {
u.state.SetSearchQuery(u.search.Text())
visible, err := u.state.VisibleEntries()
if err != nil {
u.visible = nil
return
}
u.visible = visible
u.ensureEntryClicks()
}
func (u *ui) ensureEntryClicks() {
if len(u.entryClicks) >= len(u.visible) {
return
}
next := make([]widget.Clickable, len(u.visible))
copy(next, u.entryClicks)
u.entryClicks = next
}
func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
visible := append([]entry(nil), u.visible...)
u.ensureEntryClicks()
clicks := make([]*widget.Clickable, len(visible))
for i := range visible {
clicks[i] = &u.entryClicks[i]
}
return visible, clicks
}
func (u *ui) searchPlaceholder() string {
switch u.state.Section {
case appstate.SectionAPITokens:
return "Search API tokens"
case appstate.SectionAPIAudit:
return "Search audit log"
case appstate.SectionRecycleBin:
return "Search recycle bin"
case appstate.SectionAbout:
return "Search disabled on About"
default:
return "Search vault"
}
}
func defaultStatePaths(stateDir string) statePaths {
baseDir := strings.TrimSpace(stateDir)
if baseDir == "" {
baseDir = strings.TrimSpace(os.Getenv("KEEPASSGO_STATE_DIR"))
}
if baseDir == "" {
configDir, err := os.UserConfigDir()
if err != nil || strings.TrimSpace(configDir) == "" {
configDir = os.TempDir()
}
baseDir = filepath.Join(configDir, "keepassgo")
}
return statePaths{
DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"),
SettingsPath: filepath.Join(baseDir, "settings.json"),
UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"),
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
}
}
func resolveFlagOrEnv(flagValue, envName, fallback string) string {
if value := strings.TrimSpace(flagValue); value != "" {
return value
}
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
return value
}
return fallback
}
func defaultModeForRuntime(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "phone"
}
return "desktop"
}
func shouldUsePreviewWindowSize(mode, goos string) bool {
return !strings.EqualFold(strings.TrimSpace(goos), "android")
}
func supportsDesktopFilePicker(goos string) bool {
return !strings.EqualFold(strings.TrimSpace(goos), "android")
}
func supportsSharedVaultImport(goos string) bool {
return strings.EqualFold(strings.TrimSpace(goos), "android")
}
func supportsVaultShare(goos string) bool {
return strings.EqualFold(strings.TrimSpace(goos), "android")
}
func (u *ui) selectedAttachmentItems() []attachmentItem {
item, ok := u.selectedEntry()
if !ok || len(item.Attachments) == 0 {
return nil
}
items := make([]attachmentItem, 0, len(item.Attachments))
for name, content := range item.Attachments {
items = append(items, attachmentItem{Name: name, Size: len(content)})
}
slices.SortFunc(items, func(a, b attachmentItem) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
default:
return 0
}
})
if len(u.attachmentClicks) < len(items) {
u.attachmentClicks = make([]widget.Clickable, len(items))
}
return items
}
func (u *ui) selectedAttachmentNames() []string {
items := u.selectedAttachmentItems()
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item.Name)
}
return names
}
func (u *ui) showEntriesSection() {
u.resetPasswordPeek()
u.state.ShowSection(appstate.SectionEntries)
u.mainMenuOpen = false
u.restoreEntriesSectionState()
u.filter()
}
func (u *ui) showTemplatesSection() {
u.resetPasswordPeek()
u.rememberEntriesSectionState()
u.state.ShowSection(appstate.SectionTemplates)
u.mainMenuOpen = false
u.filter()
}
func (u *ui) showRecycleBinSection() {
u.resetPasswordPeek()
u.rememberEntriesSectionState()
u.state.ShowSection(appstate.SectionRecycleBin)
u.mainMenuOpen = false
u.filter()
}
func (u *ui) showAPITokensSection() {
u.resetPasswordPeek()
u.rememberEntriesSectionState()
u.state.ShowSection(appstate.SectionAPITokens)
u.mainMenuOpen = false
u.loadSelectedAPITokenIntoEditor()
u.filter()
}
func (u *ui) showAPIAuditSection() {
u.resetPasswordPeek()
u.rememberEntriesSectionState()
u.state.ShowSection(appstate.SectionAPIAudit)
u.mainMenuOpen = false
u.selectedAuditIndex = -1
u.filter()
}
func (u *ui) showAboutSection() {
u.resetPasswordPeek()
u.rememberEntriesSectionState()
u.state.ShowSection(appstate.SectionAbout)
u.mainMenuOpen = false
u.filter()
}
func (u *ui) returnToMainEntries() {
u.clearDeleteGroupConfirmation()
u.showEntriesSection()
}
func (u *ui) handlePhoneBack() bool {
if u.mode != "phone" {
return false
}
switch {
case u.securityDialogOpen:
u.securityDialogOpen = false
case u.remotePrefsDialogOpen:
u.remotePrefsDialogOpen = false
case u.syncDialogOpen:
u.syncDialogOpen = false
case u.mainMenuOpen:
u.mainMenuOpen = false
case u.state.Section != appstate.SectionEntries:
u.returnToMainEntries()
default:
return false
}
return true
}
func (u *ui) resetPasswordPeek() {
u.showPassword = false
}
func (u *ui) childGroups() []string {
u.state.SetSearchQuery(u.search.Text())
groups, err := u.state.ChildGroups()
if err != nil {
return nil
}
return groups
}
func (u *ui) passwordProfileOptionsText() string {
return "Available profiles: " + strings.Join(passwords.DefaultProfileNames(), ", ")
}
func (u *ui) filteredTitles() []string {
titles := make([]string, 0, len(u.visible))
for _, item := range u.visible {
titles = append(titles, item.Title)
}
return titles
}
func (u *ui) visiblePathContexts() []string {
paths := make([]string, 0, len(u.visible))
for _, item := range u.visible {
paths = append(paths, u.state.SearchPathContext(item))
}
return paths
}
func (u *ui) selectedEntry() (entry, bool) {
for _, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
return item, true
}
}
model, err := u.state.Session.Current()
if err != nil {
return entry{}, false
}
for _, item := range model.Entries {
if item.ID == u.state.SelectedEntryID {
return item, true
}
}
for _, item := range model.Templates {
if item.ID == u.state.SelectedEntryID {
return item, true
}
}
for _, item := range model.RecycleBin {
if item.ID == u.state.SelectedEntryID {
return item, true
}
}
return entry{}, false
}
func (u *ui) ensureHistoryClickables() {
history := u.visibleHistory()
if len(u.historyClicks) < len(history) {
u.historyClicks = make([]widget.Clickable, len(history))
}
}
func (u *ui) currentMasterKey() (vault.MasterKey, error) {
password := u.masterPassword.Text()
path := strings.TrimSpace(u.keyFilePath.Text())
if password == "" && path == "" {
return vault.MasterKey{}, fmt.Errorf("master password or key file is required")
}
if path == "" {
return vault.MasterKey{Password: password}, nil
}
content, err := os.ReadFile(path)
if err != nil {
return vault.MasterKey{}, fmt.Errorf("read key file: %w", err)
}
if len(content) == 0 {
return vault.MasterKey{}, fmt.Errorf("key file is empty")
}
return vault.MasterKey{
Password: password,
KeyFileData: content,
}, nil
}
func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {}
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}); err != nil {
return err
}
if err := u.state.CreateVault(key); err != nil {
return err
}
if u.lifecycleMode == "local" {
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
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.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}
func (u *ui) startOpenVaultAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open vault", u.openVaultAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open vault", err)
u.requestMasterPassFocus = true
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
u.requestMasterPassFocus = true
return
}
u.lastLifecycleAction = "open vault"
u.runBackgroundAction("open vault", func() (func() error, error) {
prepared, err := session.PrepareLocalOpen(path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
})
}
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) lifecycleRemoteSyncActionLabel() string {
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return "Open Vault And Set Up Remote Sync"
}
if hasBoundRecentRemote(u.recentRemotes, path) {
return "Open Vault And Open Remote Sync Settings"
}
return "Open Vault And Set Up Remote Sync"
}
func (u *ui) beginLifecycleRemoteSyncOpen() {
path := strings.TrimSpace(u.vaultPath.Text())
switch {
case path == "":
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
case hasBoundRecentRemote(u.recentRemotes, path):
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
default:
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
}
u.startOpenVaultAction()
}
func (u *ui) applyPendingLifecycleOpenIntent() {
intent := u.pendingLifecycleOpenIntent
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
switch intent {
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
u.openRemoteSyncSetupDialog()
}
}
func (u *ui) saveAction() error {
if err := u.state.Save(); err != nil {
return err
}
if err := u.synchronizeSelectedRemoteBindingOnSave(); 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
}
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
return err
} else if ok {
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
return err
}
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
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
}
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
strings.TrimSpace(u.remotePath.Text()),
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startOpenRemoteAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open remote vault", u.openRemoteAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
u.requestMasterPassFocus = true
return
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
remotePath := strings.TrimSpace(u.remotePath.Text())
u.lastLifecycleAction = "open remote vault"
u.runBackgroundAction("open remote vault", func() (func() error, error) {
binding, bindingOK := u.selectedVaultRemoteBinding()
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
if err != nil {
return nil, err
}
resolved, err := binding.Resolve(preparedLocal.Model)
if err != nil {
return nil, err
}
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}, resolved.Profile.Path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(preparedLocal)
u.vaultPath.SetText(binding.LocalVaultPath)
u.noteRecentVault(binding.LocalVaultPath)
u.restoreRecentVaultGroup(binding.LocalVaultPath)
manager.ApplyPreparedRemoteOpen(preparedRemote)
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
}
if u.hasOpenVault() {
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
return nil, err
} else if ok {
client = webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
remotePath = resolved.Profile.Path
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
}
}
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedRemoteOpen(prepared)
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
remotePath,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) lockAction() error {
u.clearMasterPassword()
if err := u.state.Lock(); err != nil {
return err
}
u.requestMasterPassFocus = true
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) unlockAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.Unlock(key); err != nil {
return err
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startUnlockAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("unlock vault", u.unlockAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
u.requestMasterPassFocus = true
return
}
encoded := append([]byte(nil), manager.EncodedBytes()...)
u.runBackgroundAction("unlock vault", func() (func() error, error) {
prepared, err := session.PrepareUnlock(encoded, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) loadSecuritySettingsFromSession() {
settings, err := u.state.SecuritySettings()
if err != nil {
return
}
u.securityCipher.SetText(settings.Cipher)
u.securityKDF.SetText(settings.KDF)
}
func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("")
}
func (u *ui) synchronizeAction() error {
if err := u.state.Synchronize(); err != nil {
return err
}
u.syncMenuOpen = false
u.filter()
return nil
}
func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeAdvanced
u.syncSourceMode = u.syncDefaultSourceMode
u.syncDirection = u.syncDefaultDirection
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
}
func (u *ui) openRemoteSyncSetupDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
u.syncSourceMode = syncSourceRemote
u.syncDirection = syncDirectionPush
u.syncSetupAutomatic.Value = true
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
u.syncSetupAutomatic.Value = false
}
}
func (u *ui) clearSyncLocalImport() {
u.syncLocalImportName = ""
u.syncLocalImportContent = nil
}
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
name := strings.TrimSpace(u.syncLocalImportName)
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
return "", nil, false
}
return name, append([]byte(nil), u.syncLocalImportContent...), true
}
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
switch mode {
case syncSourceRemote:
return syncSourceRemote
default:
return syncSourceLocal
}
}
func sanitizeSyncDirection(direction syncDirection) syncDirection {
switch direction {
case syncDirectionPush:
return syncDirectionPush
default:
return syncDirectionPull
}
}
func (u *ui) advancedSyncAction() error {
switch u.syncDirection {
case syncDirectionPush:
return u.advancedSyncToAction()
default:
return u.advancedSyncFromAction()
}
}
func (u *ui) advancedSyncFromAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
client := webdav.Client{
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
return err
}
default:
if name, content, ok := u.selectedSyncLocalImport(); ok {
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
break
}
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) startChooseSyncLocalSourceAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose sync path", func() error {
u.clearSyncLocalImport()
return u.chooseExistingFileAction(&u.syncLocalPath)
})
return
}
u.runBackgroundAction("choose sync file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
label := "Selected Android vault"
return func() error {
u.syncLocalImportName = label
u.syncLocalImportContent = append([]byte(nil), content...)
u.syncLocalPath.SetText(label)
return nil
}, nil
})
}
func pickedDocumentName(file io.ReadCloser, fallback string) string {
if named, ok := file.(interface{ Name() string }); ok {
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
return base
}
}
fallback = filepath.Base(strings.TrimSpace(fallback))
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
return "selected-vault.kdbx"
}
return fallback
}
func (u *ui) startChooseVaultPathAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
return
}
u.runBackgroundAction("choose vault file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
name := pickedDocumentName(file, "selected-vault.kdbx")
return func() error {
return u.importSharedVaultBytesAction(name, content)
}, nil
})
}
func (u *ui) startImportSharedVaultAction() {
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
return
}
u.runBackgroundAction("import shared vault", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return func() error {
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
}, nil
})
}
func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
client := webdav.Client{
BaseURL: baseURL,
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
return err
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
return err
}
u.showStatusMessage("Remote sync is set up for this vault.")
}
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) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
baseURL = strings.TrimSpace(baseURL)
remotePath = strings.TrimSpace(remotePath)
if baseURL == "" || remotePath == "" {
return fmt.Errorf("remote setup requires base URL and path")
}
input := appstate.RemoteBindingInput{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
BaseURL: baseURL,
RemotePath: remotePath,
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
CredentialTitle: "WebDAV Sign-In" + func() string {
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
return " · " + user
}
return ""
}(),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
CredentialPath: append([]string(nil), u.currentPath...),
SyncMode: u.syncSetupMode(),
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
u.remoteBaseURL.SetText(baseURL)
u.remotePath.SetText(remotePath)
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
u.remotePassword.SetText(u.syncRemotePassword.Text())
u.noteRecentRemote(baseURL, remotePath)
return nil
}
func (u *ui) saveAsTargetPath() string {
path := strings.TrimSpace(u.saveAsPath.Text())
if path != "" {
return path
}
return u.defaultSaveAsPath
}
func (u *ui) importedVaultDestination(name string) string {
baseTarget := u.saveAsTargetPath()
baseDir := filepath.Dir(baseTarget)
baseName := filepath.Base(strings.TrimSpace(name))
switch {
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
return baseTarget
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
return filepath.Join(baseDir, baseName)
default:
return baseTarget
}
}
func (u *ui) consumePendingSharedVaultImport() {
path := strings.TrimSpace(u.pendingSharedVaultPath)
if path == "" {
return
}
content, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
}
return
}
name := "shared-vault.kdbx"
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
if rawName, err := os.ReadFile(namePath); err == nil {
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
name = trimmed
}
}
}
if err := u.importSharedVaultBytesAction(name, content); err != nil {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
return
}
_ = os.Remove(path)
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
_ = os.Remove(namePath)
}
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
return err
}
u.lifecycleMode = "local"
u.vaultPath.SetText(target)
u.noteRecentVault(target)
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.requestMasterPassFocus = true
u.filter()
return nil
}
func (u *ui) currentShareableVaultPath() string {
return strings.TrimSpace(u.vaultPath.Text())
}
func (u *ui) shareCurrentVaultAction() error {
if u.vaultSharer == nil {
return fmt.Errorf("vault sharing is not available on this platform")
}
path := u.currentShareableVaultPath()
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.Save(); err != nil {
return err
}
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
}
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)
record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath)
record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
record.SyncMode = strings.TrimSpace(record.SyncMode)
record.Username = strings.TrimSpace(record.Username)
record.Password = strings.TrimSpace(record.Password)
if record.BaseURL == "" || record.Path == "" {
continue
}
if record.Username != "" || record.Password != "" {
record.NeedsMigration = true
record.Username = ""
record.Password = ""
}
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) hasLegacyRecentRemoteCredentialMigration() bool {
for _, record := range u.recentRemotes {
if record.NeedsMigration {
return true
}
}
return false
}
func (u *ui) saveRecentVaults() {
if strings.TrimSpace(u.recentVaultsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil {
return
}
records := make([]recentVaultRecord, 0, len(u.recentVaults))
for _, path := range u.recentVaults {
records = append(records, recentVaultRecord{
Path: path,
LastGroup: append([]string(nil), u.recentVaultGroups[path]...),
UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano),
})
}
content, err := json.MarshalIndent(records, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.recentVaultsPath, content, 0o600)
}
func (u *ui) saveRecentRemotes() {
if strings.TrimSpace(u.recentRemotesPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(u.recentRemotes, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.recentRemotesPath, content, 0o600)
}
func (u *ui) loadUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs uiPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.groupControlsHidden = prefs.GroupControlsHidden
u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden
u.historyHidden = prefs.HistoryHidden
u.denseLayout = prefs.DenseLayout
u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis)
u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode)
displayDensity := strings.TrimSpace(prefs.DisplayDensity)
if displayDensity == "" {
displayDensity = displayDensityForDenseLayout(prefs.DenseLayout)
}
u.applyAccessibilityPreferences(accessibilityPreferences{
DisplayDensity: displayDensity,
Contrast: prefs.Contrast,
ReducedMotion: prefs.ReducedMotion,
KeyboardFocus: prefs.KeyboardFocus,
})
if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" {
u.autofillFirstFillApprovalMode = mode
}
u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist))
u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist))
u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules))
}
func (u *ui) saveUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(uiPreferences{
GroupControlsHidden: u.groupControlsHidden,
LifecycleAdvancedHidden: u.lifecycleAdvancedHidden,
HistoryHidden: u.historyHidden,
DenseLayout: u.denseLayout,
StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond),
AutofillNoticeMode: string(u.autofillNoticePreference),
DisplayDensity: u.accessibilityPrefs.DisplayDensity,
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
AutofillPrivacy: autofillPrivacySettings{
FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode),
BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()),
AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()),
PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()),
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.uiPreferencesPath, content, 0o600)
}
func (u *ui) loadSettingsFormFromPreferences() {
u.settingsGroupControls.Value = u.groupControlsHidden
u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden
u.settingsHistory.Value = u.historyHidden
u.settingsDenseLayout.Value = u.denseLayout
}
func (u *ui) applySettingsFormToPreferences() {
u.groupControlsHidden = u.settingsGroupControls.Value
u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value
u.historyHidden = u.settingsHistory.Value
u.denseLayout = u.settingsDenseLayout.Value
}
func normalizedStatusBannerTTL(valueMillis int) time.Duration {
switch {
case valueMillis <= 0:
return statusBannerDuration
case time.Duration(valueMillis)*time.Millisecond > statusBannerLong:
return statusBannerLong
default:
return time.Duration(valueMillis) * time.Millisecond
}
}
func normalizedAutofillNoticeMode(value string) autofillNoticeMode {
switch autofillNoticeMode(strings.TrimSpace(value)) {
case autofillNoticeApprovals:
return autofillNoticeApprovals
case autofillNoticeSuppressed:
return autofillNoticeSuppressed
default:
return autofillNoticeAll
}
}
func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode {
switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) {
case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock:
return autofillFirstFillApprovalMode(strings.TrimSpace(raw))
default:
return ""
}
}
func autofillPrivacyLines(text string) []string {
lines := strings.Split(text, "\n")
result := make([]string, 0, len(lines))
seen := make(map[string]struct{}, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if _, ok := seen[line]; ok {
continue
}
seen[line] = struct{}{}
result = append(result, line)
}
return result
}
func joinAutofillPrivacyLines(lines []string) string {
if len(lines) == 0 {
return ""
}
return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n")
}
func (u *ui) autofillRuleCount() int {
return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) +
len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) +
len(autofillPrivacyLines(u.autofillPackageRules.Text()))
}
func (u *ui) autofillFirstFillApprovalSummary() string {
switch u.autofillFirstFillApprovalMode {
case autofillFirstFillApprovalAllow:
return "New apps and packages can fill immediately until a persistent rule is created."
case autofillFirstFillApprovalBlock:
return "New apps and packages stay blocked until you add an allowlist entry or a package rule."
default:
return "KeePassGO asks before the first fill into a newly seen app or package."
}
}
func (u *ui) setStatusBannerTTL(value time.Duration) {
u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond))
u.saveUIPreferences()
}
func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) {
u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value))
u.saveUIPreferences()
}
func (u *ui) noteRecentRemote(baseURL, path string) {
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 binding, ok := u.selectedVaultRemoteBinding(); ok {
record.LocalVaultPath = binding.LocalVaultPath
record.RemoteProfileID = binding.RemoteProfileID
record.CredentialEntryID = binding.CredentialEntryID
record.SyncMode = string(binding.SyncMode)
}
if len(record.LastGroup) == 0 {
record.LastGroup = u.recentRemoteGroup(baseURL, path)
}
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 && strings.TrimSpace(remoteRecord.LocalVaultPath) != "" && (localPath == "" || remoteUsedAt.After(localUsedAt)):
u.lifecycleMode = "local"
u.vaultPath.SetText(strings.TrimSpace(remoteRecord.LocalVaultPath))
case localPath != "":
u.lifecycleMode = "local"
u.vaultPath.SetText(localPath)
case hasRemote:
u.lifecycleMode = "remote"
u.applyRecentRemoteRecord(remoteRecord)
}
}
func (u *ui) hasSelectedLifecycleTarget() bool {
switch strings.TrimSpace(u.lifecycleMode) {
case "remote":
return u.hasSelectedRemoteTarget()
default:
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
}
func (u *ui) hasSelectedRemoteTarget() bool {
return u.selectedRemoteConnection
}
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) hasSelectedVaultPath() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) showLocalVaultChooser() bool {
return u.lifecycleMode != "local" || !u.hasSelectedVaultPath()
}
func (u *ui) showRemoteConnectionChooser() bool {
return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget()
}
func (u *ui) switchToLifecycleSelection(mode string) {
u.state.Session = &session.Manager{}
u.state.CurrentPath = nil
u.state.SelectedEntryID = ""
u.state.Section = appstate.SectionEntries
u.state.Dirty = false
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.loadingMessage = ""
u.loadingActionLabel = ""
u.lastLifecycleAction = ""
u.lifecycleMode = mode
u.editingEntry = false
u.currentPath = nil
u.syncedPath = nil
u.clearMasterPassword()
u.keyFilePath.SetText("")
u.search.SetText("")
switch mode {
case "remote":
u.vaultPath.SetText("")
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.selectedRemoteConnection = false
default:
u.vaultPath.SetText("")
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.selectedRemoteConnection = false
}
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.filter()
}
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
for _, record := range u.recentRemotes {
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
continue
}
usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt))
if err != nil {
usedAt = time.Time{}
}
return record, true, usedAt
}
return recentRemoteRecord{}, false, time.Time{}
}
func (u *ui) currentRemoteRecord() recentRemoteRecord {
return recentRemoteRecord{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Path: strings.TrimSpace(u.remotePath.Text()),
}
}
func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) {
record := u.currentRemoteRecord()
if record.BaseURL == "" || record.Path == "" {
return recentRemoteRecord{}, false
}
for _, existing := range u.recentRemotes {
if existing.BaseURL == record.BaseURL && existing.Path == record.Path {
return existing, true
}
}
return recentRemoteRecord{}, false
}
func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.remoteBaseURL.SetText(record.BaseURL)
u.remotePath.SetText(record.Path)
u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath))
u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
u.remotePassword.Mask = '•'
u.selectedRemoteConnection = true
if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" {
u.showStatusMessage("This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it.")
}
}
func (u *ui) remotePreferencesCurrentSummary() string {
switch {
case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "":
return "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile."
default:
return "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state."
}
}
func (u *ui) remotePreferencesAlwaysSavedSummary() string {
return "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection."
}
func (u *ui) remotePreferencesRetentionSummary() string {
return "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls."
}
func (u *ui) remotePreferencesPersistenceSummary() string {
return "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself."
}
func (u *ui) availableRemoteProfiles() []vault.RemoteProfile {
profiles, err := u.state.RemoteProfiles()
if err != nil {
return nil
}
return profiles
}
func (u *ui) availableRemoteCredentialEntries() []vault.Entry {
entries, err := u.state.RemoteCredentialEntries()
if err != nil {
return nil
}
return entries
}
func normalizeRemoteCredentialURL(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, "/")
return raw
}
func remoteCredentialURLMatches(candidate, target string) bool {
candidate = normalizeRemoteCredentialURL(candidate)
target = normalizeRemoteCredentialURL(target)
if candidate == "" || target == "" {
return false
}
if candidate == target {
return true
}
candidateURL, err := url.Parse(candidate)
if err != nil {
return false
}
targetURL, err := url.Parse(target)
if err != nil {
return false
}
if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) {
return false
}
candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/")
targetPath := strings.TrimRight(targetURL.EscapedPath(), "/")
if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" {
return true
}
return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath)
}
func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote {
return nil
}
baseURL := normalizeRemoteCredentialURL(u.syncRemoteBaseURL.Text())
if baseURL == "" {
return nil
}
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
entries := u.availableRemoteCredentialEntries()
byID := make(map[string]vault.Entry, len(entries))
for _, entry := range entries {
byID[entry.ID] = entry
}
matches := make([]vault.Entry, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
appendMatch := func(entry vault.Entry) {
if strings.TrimSpace(entry.ID) == "" {
return
}
if _, ok := seen[entry.ID]; ok {
return
}
seen[entry.ID] = struct{}{}
matches = append(matches, entry)
}
for _, entry := range entries {
if !remoteCredentialURLMatches(entry.URL, baseURL) {
continue
}
appendMatch(entry)
}
profilesByID := make(map[string]vault.RemoteProfile)
for _, profile := range u.availableRemoteProfiles() {
profilesByID[profile.ID] = profile
}
localVaultPath := strings.TrimSpace(u.vaultPath.Text())
for _, record := range u.recentRemotes {
if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath {
continue
}
profile, ok := profilesByID[strings.TrimSpace(record.RemoteProfileID)]
if !ok {
continue
}
if !remoteCredentialURLMatches(profile.BaseURL, baseURL) {
continue
}
if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath {
continue
}
entry, ok := byID[strings.TrimSpace(record.CredentialEntryID)]
if !ok {
continue
}
appendMatch(entry)
}
return matches
}
func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) {
u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID)
u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username))
u.syncRemotePassword.SetText(entry.Password)
}
func (u *ui) savedAdvancedSyncRemoteBinding() (appstate.ResolvedRemoteBinding, bool) {
if !u.hasOpenVault() {
return appstate.ResolvedRemoteBinding{}, false
}
_, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding()
if err != nil || !ok {
return appstate.ResolvedRemoteBinding{}, false
}
return resolved, true
}
func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() {
resolved, ok := u.savedAdvancedSyncRemoteBinding()
if !ok {
return
}
u.syncRemoteBaseURL.SetText(resolved.Profile.BaseURL)
u.syncRemotePath.SetText(resolved.Profile.Path)
u.applyAdvancedSyncRemoteCredentialEntry(resolved.Credentials)
}
func (u *ui) syncDialogTitle() string {
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if _, ok := u.selectedVaultRemoteBinding(); ok {
return "Remote Sync Settings"
}
return "Set Up Remote Sync"
}
return "Advanced Sync"
}
func (u *ui) syncDialogDescription() string {
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if _, ok := u.selectedVaultRemoteBinding(); ok {
return "Review or change this vault's saved WebDAV target, credentials, and sync mode."
}
return "Send this local vault to a WebDAV target, then use that target for future sync."
}
return "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings."
}
func (u *ui) syncDialogConfirmButtonLabel() string {
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if _, ok := u.selectedVaultRemoteBinding(); ok {
return "Save Remote Sync Settings"
}
return "Set Up Remote Sync"
}
return "Synchronize"
}
func (u *ui) shouldShowSyncDirectionChoices() bool {
return u.syncDialogPurpose != syncDialogPurposeRemoteSetup
}
func (u *ui) shouldShowSyncSourceChoices() bool {
return u.syncDialogPurpose != syncDialogPurposeRemoteSetup
}
func (u *ui) syncSetupMode() appstate.SyncMode {
if u.syncSetupAutomatic.Value {
return appstate.SyncModeAutomaticOnOpenSave
}
return appstate.SyncModeManual
}
func (u *ui) selectVaultRemoteProfile(id string) {
id = strings.TrimSpace(id)
u.selectedVaultRemoteProfileID = id
for _, profile := range u.availableRemoteProfiles() {
if profile.ID != id {
continue
}
u.remoteBaseURL.SetText(profile.BaseURL)
u.remotePath.SetText(profile.Path)
return
}
}
func (u *ui) selectVaultRemoteCredentialEntry(id string) {
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id)
}
func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) {
selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
profiles := u.availableRemoteProfiles()
for _, profile := range profiles {
if profile.ID == selectedID {
return profile, true
}
}
if selectedID == "" && len(profiles) == 1 {
return profiles[0], true
}
return vault.RemoteProfile{}, false
}
func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) {
selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
entries := u.availableRemoteCredentialEntries()
for _, entry := range entries {
if entry.ID == selectedID {
return entry, true
}
}
if selectedID == "" && len(entries) == 1 {
return entries[0], true
}
return vault.Entry{}, false
}
func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
if profileID != "" && entryID != "" {
return appstate.RemoteBinding{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: profileID,
CredentialEntryID: entryID,
SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}, true
}
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return appstate.RemoteBinding{}, false
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return appstate.RemoteBinding{}, false
}
return appstate.RemoteBinding{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: profile.ID,
CredentialEntryID: entry.ID,
SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}, true
}
func normalizeUISyncMode(mode appstate.SyncMode) appstate.SyncMode {
switch mode {
case appstate.SyncModeAutomaticOnOpenSave:
return appstate.SyncModeAutomaticOnOpenSave
default:
return appstate.SyncModeManual
}
}
func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode {
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
return appstate.SyncModeAutomaticOnOpenSave
}
if u.selectedVaultRemoteSyncMode == "" {
return appstate.SyncModeAutomaticOnOpenSave
}
return appstate.SyncModeManual
}
func (u *ui) syncSavedRemoteBindingSelection() {
profiles := u.availableRemoteProfiles()
entries := u.availableRemoteCredentialEntries()
profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
if profileID != "" {
var found bool
for _, profile := range profiles {
if profile.ID == profileID {
found = true
break
}
}
if !found {
u.selectedVaultRemoteProfileID = ""
}
}
if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" && len(profiles) == 1 {
u.selectedVaultRemoteProfileID = profiles[0].ID
}
if profile, ok := u.selectedVaultRemoteProfile(); ok {
u.remoteBaseURL.SetText(profile.BaseURL)
u.remotePath.SetText(profile.Path)
}
entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
if entryID != "" {
var found bool
for _, entry := range entries {
if entry.ID == entryID {
found = true
break
}
}
if !found {
u.selectedVaultRemoteCredentialEntryID = ""
}
}
if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 {
u.selectedVaultRemoteCredentialEntryID = entries[0].ID
}
if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" {
if record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())); ok {
u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
if profile, ok := u.selectedVaultRemoteProfile(); ok {
u.remoteBaseURL.SetText(profile.BaseURL)
u.remotePath.SetText(profile.Path)
}
}
}
if binding, ok := u.selectedVaultRemoteBinding(); ok {
for _, record := range u.recentRemotes {
if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) {
continue
}
if strings.TrimSpace(record.RemoteProfileID) != strings.TrimSpace(binding.RemoteProfileID) {
continue
}
if strings.TrimSpace(record.CredentialEntryID) != strings.TrimSpace(binding.CredentialEntryID) {
continue
}
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
return
}
}
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
}
func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) {
path = strings.TrimSpace(path)
if path == "" {
return recentRemoteRecord{}, false
}
return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path)
}
func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool {
_, ok := boundRecentRemoteForLocalVaultRecords(records, strings.TrimSpace(path))
return ok
}
func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) {
var matches []recentRemoteRecord
for _, record := range records {
if strings.TrimSpace(record.LocalVaultPath) != path {
continue
}
if strings.TrimSpace(record.RemoteProfileID) == "" || strings.TrimSpace(record.CredentialEntryID) == "" {
continue
}
matches = append(matches, record)
}
if len(matches) != 1 {
return recentRemoteRecord{}, false
}
return matches[0], true
}
func (u *ui) shouldShowSavedRemoteBindingSelectors() bool {
profiles := u.availableRemoteProfiles()
entries := u.availableRemoteCredentialEntries()
if len(profiles) == 0 || len(entries) == 0 {
return false
}
return len(profiles) > 1 || len(entries) > 1
}
func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) {
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return "", "", "", false
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return "", "", "", false
}
credentialLabel = entry.Title
if strings.TrimSpace(entry.Username) != "" {
credentialLabel += " · " + strings.TrimSpace(entry.Username)
}
syncLabel = "Sync manually when you choose Use Remote Sync."
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
syncLabel = "Syncs automatically on open and save."
}
return profile.Name, credentialLabel, syncLabel, true
}
func (u *ui) savedRemoteBindingHeading() string {
if !u.shouldShowSavedRemoteBindingSelectors() {
return "Use this vault's saved remote sync target"
}
return "Use a saved remote profile from this vault"
}
func (u *ui) openSelectedVaultRemoteButtonLabel() string {
if !u.shouldShowSavedRemoteBindingSelectors() {
return "Use Remote Sync"
}
return "Open Saved Remote"
}
func (u *ui) shouldShowDirectRemoteSyncShortcut() bool {
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return false
}
_, ok := u.selectedVaultRemoteBinding()
return ok
}
func (u *ui) directRemoteSyncShortcutLabel() string {
return "Use Remote Sync"
}
func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool {
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return false
}
_, ok := u.selectedVaultRemoteBinding()
return ok
}
func (u *ui) remoteSyncSettingsShortcutLabel() string {
return "Remote Sync Settings"
}
func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool {
return u.shouldShowRemoteSyncSettingsShortcut()
}
func (u *ui) removeRemoteSyncShortcutLabel() string {
return "Stop Using Remote Sync"
}
func (u *ui) shouldShowRemoteSyncSetupShortcut() bool {
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return false
}
_, ok := u.selectedVaultRemoteBinding()
return !ok
}
func (u *ui) remoteSyncSetupShortcutLabel() string {
return "Set Up Remote Sync"
}
func (u *ui) syncMenuActionLabels() []string {
labels := []string{"Open Advanced Sync"}
if u.shouldShowRemoteSyncSetupShortcut() {
labels = append(labels, u.remoteSyncSetupShortcutLabel())
}
if u.shouldShowDirectRemoteSyncShortcut() {
labels = append(labels, u.directRemoteSyncShortcutLabel())
}
if u.shouldShowRemoteSyncSettingsShortcut() {
labels = append(labels, u.remoteSyncSettingsShortcutLabel())
}
if u.shouldShowRemoveRemoteSyncShortcut() {
labels = append(labels, u.removeRemoteSyncShortcutLabel())
}
return labels
}
func remoteBindingSuffix(baseURL, path, username string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username)))
return hex.EncodeToString(sum[:8])
}
func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
remotePath := strings.TrimSpace(u.remotePath.Text())
username := strings.TrimSpace(u.remoteUsername.Text())
password := u.remotePassword.Text()
localVaultPath := strings.TrimSpace(u.vaultPath.Text())
switch {
case localVaultPath == "":
return appstate.RemoteBindingInput{}, fmt.Errorf("local vault path is required")
case baseURL == "":
return appstate.RemoteBindingInput{}, fmt.Errorf("remote base URL is required")
case remotePath == "":
return appstate.RemoteBindingInput{}, fmt.Errorf("remote path is required")
case username == "":
return appstate.RemoteBindingInput{}, fmt.Errorf("remote username is required")
case password == "":
return appstate.RemoteBindingInput{}, fmt.Errorf("remote password is required")
}
suffix := remoteBindingSuffix(baseURL, remotePath, username)
credentialTitle := "WebDAV Sign-In"
if username != "" {
credentialTitle += " · " + username
}
return appstate.RemoteBindingInput{
LocalVaultPath: localVaultPath,
RemoteProfileID: "remote-profile-" + suffix,
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
BaseURL: baseURL,
RemotePath: remotePath,
CredentialEntryID: "remote-credential-" + suffix,
CredentialTitle: credentialTitle,
Username: username,
Password: password,
CredentialPath: append([]string(nil), u.currentPath...),
SyncMode: u.newRemoteBindingSyncMode(),
}, nil
}
func (u *ui) saveCurrentRemoteBindingAction() error {
input, err := u.currentRemoteBindingInput()
if err != nil {
return err
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
return nil
}
func (u *ui) stripRecentRemoteBinding(binding appstate.RemoteBinding) {
localPath := strings.TrimSpace(binding.LocalVaultPath)
profileID := strings.TrimSpace(binding.RemoteProfileID)
credentialID := strings.TrimSpace(binding.CredentialEntryID)
for i := range u.recentRemotes {
record := &u.recentRemotes[i]
if strings.TrimSpace(record.LocalVaultPath) != localPath {
continue
}
if strings.TrimSpace(record.RemoteProfileID) != profileID {
continue
}
if strings.TrimSpace(record.CredentialEntryID) != credentialID {
continue
}
record.LocalVaultPath = ""
record.RemoteProfileID = ""
record.CredentialEntryID = ""
record.SyncMode = ""
}
}
func (u *ui) removeSelectedRemoteBindingAction() error {
binding, ok := u.selectedVaultRemoteBinding()
if !ok {
return fmt.Errorf("no saved remote sync target is selected")
}
if err := u.state.RemoveRemoteBinding(binding); err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.stripRecentRemoteBinding(binding)
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.showStatusMessage("Remote sync is no longer set up for this vault.")
return nil
}
func (u *ui) saveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target"
}
func (u *ui) saveCurrentRemoteBindingButtonLabel() string {
return "Save Remote In Vault"
}
func (u *ui) materializeCurrentRemoteCache() error {
cachePath := strings.TrimSpace(u.vaultPath.Text())
if cachePath == "" {
cachePath = u.saveAsTargetPath()
}
if cachePath == "" {
return nil
}
u.vaultPath.SetText(cachePath)
if err := u.state.SaveAs(cachePath); err != nil {
return err
}
u.noteRecentVault(cachePath)
username := strings.TrimSpace(u.remoteUsername.Text())
password := u.remotePassword.Text()
if username == "" && password == "" {
return nil
}
input, err := u.currentRemoteBindingInput()
if err != nil {
return err
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
if err := u.state.SaveAs(cachePath); err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
return nil
}
func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
if u.hasOpenVault() {
return u.resolvedSelectedVaultRemoteBinding()
}
binding, ok := u.selectedVaultRemoteBinding()
if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
}
if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
}
u.vaultPath.SetText(binding.LocalVaultPath)
u.noteRecentVault(binding.LocalVaultPath)
u.restoreRecentVaultGroup(binding.LocalVaultPath)
model, err := u.state.Session.Current()
if err != nil {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
}
resolved, err := binding.Resolve(model)
if err != nil {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
}
return binding, resolved, true, nil
}
func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
binding, ok := u.selectedVaultRemoteBinding()
if !ok {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
}
model, err := u.state.Session.Current()
if err != nil {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
}
resolved, err := binding.Resolve(model)
if err != nil {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
}
return binding, resolved, true, nil
}
func (u *ui) noteCurrentRemotePath() {
status, ok := u.state.Session.(sessionStatus)
if !ok || !status.IsRemote() || status.IsLocked() {
return
}
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text())
if baseURL == "" || path == "" {
return
}
for i := range u.recentRemotes {
if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path {
continue
}
u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...)
u.saveRecentRemotes()
return
}
}
func (u *ui) recentVaultGroup(path string) []string {
if u.recentVaultGroups == nil {
return nil
}
return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...)
}
func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries {
return ""
}
model, err := u.state.Session.Current()
if err != nil {
return ""
}
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func (u *ui) enterHiddenVaultRoot() {
root := u.hiddenVaultRoot()
if root == "" {
return
}
u.setCurrentPath([]string{root})
}
func (u *ui) restoreRecentVaultGroup(path string) {
saved := u.recentVaultGroup(path)
if len(saved) == 0 {
u.enterHiddenVaultRoot()
return
}
model, err := u.state.Session.Current()
if err != nil {
u.enterHiddenVaultRoot()
return
}
root := u.hiddenVaultRoot()
if len(saved) == 1 && root != "" && saved[0] == root {
u.setCurrentPath(saved)
return
}
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
u.setCurrentPath(saved)
return
}
u.enterHiddenVaultRoot()
}
func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
saved := u.recentRemoteGroup(baseURL, path)
if len(saved) == 0 {
u.enterHiddenVaultRoot()
return
}
model, err := u.state.Session.Current()
if err != nil {
u.enterHiddenVaultRoot()
return
}
root := u.hiddenVaultRoot()
if len(saved) == 1 && root != "" && saved[0] == root {
u.setCurrentPath(saved)
return
}
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
u.setCurrentPath(saved)
return
}
u.enterHiddenVaultRoot()
}
func (u *ui) restoreEntriesPath(path []string) {
if len(path) == 0 {
u.enterHiddenVaultRoot()
return
}
model, err := u.state.Session.Current()
if err != nil {
u.enterHiddenVaultRoot()
return
}
root := u.hiddenVaultRoot()
if len(path) == 1 && root != "" && path[0] == root {
u.setCurrentPath(path)
return
}
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) {
u.setCurrentPath(path)
return
}
u.enterHiddenVaultRoot()
}
func (u *ui) rememberEntriesSectionState() {
if u.state.Section != appstate.SectionEntries {
return
}
u.entriesState = entriesSectionState{
Path: append([]string(nil), u.currentPath...),
SearchQuery: u.search.Text(),
SelectedEntryID: u.state.SelectedEntryID,
Editing: u.editingEntry,
}
}
func (u *ui) restoreEntriesSectionState() {
u.search.SetText(u.entriesState.SearchQuery)
u.restoreEntriesPath(u.entriesState.Path)
u.state.SelectedEntryID = u.entriesState.SelectedEntryID
u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != ""
if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" {
u.loadSelectedEntryIntoEditor()
}
}
func (u *ui) displayPath() []string {
path := append([]string(nil), u.currentPath...)
root := u.hiddenVaultRoot()
if root == "" || len(path) == 0 || path[0] != root {
return path
}
return append([]string(nil), path[1:]...)
}
func (u *ui) displayEntryPath(path []string) []string {
root := u.hiddenVaultRoot()
if root == "" || len(path) == 0 || path[0] != root {
return append([]string(nil), path...)
}
return append([]string(nil), path[1:]...)
}
func (u *ui) currentGroupDisplayName() string {
displayPath := u.displayPath()
if len(displayPath) == 0 {
return "Vault root (/)"
}
return strings.Join(displayPath, " / ")
}
func (u *ui) parentGroupDisplayName() string {
displayPath := u.displayPath()
if len(displayPath) <= 1 {
return "Vault root (/)"
}
return strings.Join(displayPath[:len(displayPath)-1], " / ")
}
func (u *ui) createGroupLabel() string {
if len(u.displayPath()) == 0 {
return "Create Top-Level Group"
}
return "Create Subgroup"
}
func pathHasPrefix(path, prefix []string) bool {
if len(prefix) > len(path) {
return false
}
return slices.Equal(path[:len(prefix)], prefix)
}
func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups {
if slices.Equal(group, path) {
return true
}
}
return false
}
func (u *ui) currentGroupDeletionState() (bool, string) {
u.syncCurrentPath()
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
return false, ""
}
model, err := u.state.Session.Current()
if err != nil {
return false, ""
}
path := append([]string(nil), u.currentPath...)
if len(model.ChildGroups(path)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group."
}
for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
return false, "This group contains entries. Move or delete them before removing the group."
}
}
for _, item := range model.Templates {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
return false, "This group contains templates. Move or delete them before removing the group."
}
}
return true, "Deleting this empty group will not remove any entries."
}
func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
}
func (u *ui) clearDeleteGroupConfirmation() {
u.deleteGroupPath = nil
}
func (u *ui) armDeleteCurrentGroupAction() {
if deletable, _ := u.currentGroupDeletionState(); !deletable {
return
}
u.syncCurrentPath()
u.deleteGroupPath = append([]string(nil), u.currentPath...)
u.state.ErrorMessage = ""
u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")))
}
func (u *ui) runAction(label string, action func() error) {
if strings.TrimSpace(u.loadingMessage) != "" {
return
}
u.loadingMessage = actionLoadingLabel(label)
u.loadingActionLabel = strings.TrimSpace(label)
if err := action(); err != nil {
u.loadingMessage = ""
u.loadingActionLabel = ""
u.state.ErrorMessage = u.describeActionError(label, err)
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.loadingMessage = ""
u.loadingActionLabel = ""
u.syncAutofillCache()
u.state.ErrorMessage = ""
if suppressStatusMessage(label) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.showStatusMessage(label + " complete")
}
func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) {
if strings.TrimSpace(u.loadingMessage) != "" {
return
}
u.backgroundActionSerial++
actionID := u.backgroundActionSerial
u.activeBackgroundAction = actionID
u.loadingMessage = actionLoadingLabel(label)
u.loadingActionLabel = strings.TrimSpace(label)
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
go func() {
apply, err := prepare()
u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID}
if u.invalidate != nil {
u.invalidate()
}
}()
}
func (u *ui) applyBackgroundResult(result backgroundActionResult) {
if result.id != 0 && result.id != u.activeBackgroundAction {
return
}
u.activeBackgroundAction = 0
u.loadingMessage = ""
u.loadingActionLabel = ""
if result.err != nil {
u.state.ErrorMessage = u.describeActionError(result.label, result.err)
if strings.HasPrefix(result.label, "open ") {
u.requestMasterPassFocus = true
}
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
if result.apply != nil {
if err := result.apply(); err != nil {
u.state.ErrorMessage = u.describeActionError(result.label, err)
if strings.HasPrefix(result.label, "open ") {
u.requestMasterPassFocus = true
}
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
}
u.syncAutofillCache()
u.state.ErrorMessage = ""
if suppressStatusMessage(result.label) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.showStatusMessage(result.label + " complete")
}
func (u *ui) cancelLifecycleBusyState() {
if !u.lifecycleBusy() {
return
}
u.activeBackgroundAction = 0
u.loadingMessage = ""
u.loadingActionLabel = ""
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
u.requestMasterPassFocus = true
}
func (u *ui) retryLastLifecycleOpen() {
switch strings.TrimSpace(u.lastLifecycleAction) {
case "open vault":
u.startOpenVaultAction()
case "open remote vault":
u.startOpenRemoteAction()
}
}
func (u *ui) canRetryLifecycleOpen() bool {
if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" {
return false
}
switch strings.TrimSpace(u.lastLifecycleAction) {
case "open vault", "open remote vault":
return true
default:
return false
}
}
func (u *ui) processBackgroundActions() {
for {
select {
case result := <-u.backgroundResults:
u.applyBackgroundResult(result)
default:
return
}
}
}
func (u *ui) syncAutofillCache() {
if strings.TrimSpace(u.autofillCachePath) == "" {
return
}
model, err := u.state.Session.Current()
if err != nil {
_ = autofillcache.Clear(u.autofillCachePath)
return
}
_ = autofillcache.Write(u.autofillCachePath, model, u.now())
}
func suppressStatusMessage(label string) bool {
switch strings.TrimSpace(label) {
case "open vault", "open remote vault":
return true
default:
return false
}
}
func actionLoadingLabel(label string) string {
label = strings.TrimSpace(label)
if label == "" {
return "Working..."
}
runes := []rune(label)
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
return string(runes) + "..."
}
func (u *ui) describeActionError(label string, err error) string {
if err == nil {
return ""
}
if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) {
return "Save conflict: the remote vault changed. Reopen it and retry the save."
}
if label == "open remote vault" {
return fmt.Sprintf("%s failed: %v", label, err)
}
return err.Error()
}
func (u *ui) remoteOpenRetryAvailable() bool {
return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:")
}
func (u *ui) selectedRemoteUsesLocalCache() bool {
return u.hasSelectedRemoteTarget() &&
strings.TrimSpace(u.vaultPath.Text()) != "" &&
strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" &&
strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != ""
}
func (u *ui) currentSessionIsRemote() bool {
session, ok := u.state.Session.(interface{ IsRemote() bool })
return ok && session.IsRemote()
}
func (u *ui) resolvedSelectedVaultRemoteBindingForAutoSync() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding()
if err == nil || !ok {
return binding, resolved, ok, err
}
message := err.Error()
if strings.Contains(message, "resolve remote profile:") || strings.Contains(message, "resolve remote credentials:") {
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
}
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
}
func (u *ui) synchronizeSelectedRemoteBindingOnOpen() error {
if u.currentSessionIsRemote() {
return nil
}
binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync()
if err != nil || !ok {
return err
}
if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave {
return nil
}
client := webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
if err := u.state.SynchronizeFromRemote(client, resolved.Profile.Path); err != nil {
return err
}
if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil {
return err
}
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
return nil
}
func (u *ui) synchronizeSelectedRemoteBindingOnSave() error {
if u.currentSessionIsRemote() {
return nil
}
binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync()
if err != nil || !ok {
return err
}
if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave {
return nil
}
client := webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
if err := u.state.SynchronizeToRemote(client, resolved.Profile.Path); err != nil {
return err
}
if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
return nil
}
func (u *ui) reapplyResolvedRemoteBinding(binding appstate.RemoteBinding, resolved appstate.ResolvedRemoteBinding) error {
_, err := u.state.ConfigureRemoteBinding(appstate.RemoteBindingInput{
LocalVaultPath: binding.LocalVaultPath,
RemoteProfileID: resolved.Profile.ID,
RemoteProfileName: resolved.Profile.Name,
BaseURL: resolved.Profile.BaseURL,
RemotePath: resolved.Profile.Path,
CredentialEntryID: resolved.Credentials.ID,
CredentialTitle: resolved.Credentials.Title,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
CredentialPath: append([]string(nil), resolved.Credentials.Path...),
SyncMode: binding.SyncMode,
})
if err != nil {
return err
}
u.selectedVaultRemoteSyncMode = binding.SyncMode
return nil
}
func (u *ui) remoteLifecycleMessage() string {
if u.selectedRemoteUsesLocalCache() {
return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings."
}
return "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly."
}
func (u *ui) remoteOpenButtonLabel() string {
switch {
case u.lifecycleBusy():
if u.selectedRemoteUsesLocalCache() {
return "Opening Cached Vault..."
}
return "Creating Local Cache..."
case u.remoteOpenRetryAvailable():
if u.selectedRemoteUsesLocalCache() {
return "Retry Cached Vault"
}
return "Retry Local Cache Setup"
default:
if u.selectedRemoteUsesLocalCache() {
return "Open Cached Vault"
}
return "Create Local Cache"
}
}
func (u *ui) remoteLifecycleSetupSummary() string {
return "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target."
}
func (u *ui) bannerSurface() uiBanner {
switch {
case strings.TrimSpace(u.loadingMessage) != "":
return uiBanner{
Kind: bannerLoading,
Message: strings.TrimSpace(u.loadingMessage),
Detail: u.loadingDetailMessage(),
}
case strings.TrimSpace(u.state.ErrorMessage) != "":
return uiBanner{
Kind: bannerError,
Message: strings.TrimSpace(u.state.ErrorMessage),
Dismissable: true,
}
default:
return uiBanner{}
}
}
func (u *ui) statusToastSurface() uiBanner {
if strings.TrimSpace(u.state.StatusMessage) == "" {
return uiBanner{}
}
if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return uiBanner{}
}
return uiBanner{
Kind: bannerStatus,
Message: strings.TrimSpace(u.state.StatusMessage),
}
}
func (u *ui) autofillStatusSurface() uiAutofillStatus {
if u.autofillNoticePreference == autofillNoticeSuppressed {
return uiAutofillStatus{}
}
if request, ok := u.pendingAutofillApproval(); ok {
detail := approvalResourceText(request)
if strings.TrimSpace(detail) == "" {
detail = "Review the request to allow or deny this fill attempt."
}
return uiAutofillStatus{
Kind: autofillStatusAwaitingApproval,
Title: "Autofill approval needed",
Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.",
Detail: detail,
}
}
if u.auditLog == nil {
return uiAutofillStatus{}
}
if u.autofillNoticePreference == autofillNoticeApprovals {
return uiAutofillStatus{}
}
for _, event := range u.auditLog.Events() {
if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok {
return status
}
}
return uiAutofillStatus{}
}
func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) {
for _, request := range u.state.PendingApprovals() {
if isAutofillOperation(request.Operation) {
return request, true
}
}
return apiapproval.Request{}, false
}
func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) {
if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL {
return uiAutofillStatus{}, false
}
requester := formatAutofillRequester(event.ClientName, event.TokenName)
switch event.Type {
case apiaudit.EventAutofillFound:
return uiAutofillStatus{
Kind: autofillStatusFound,
Title: "Autofill match ready",
Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."),
Detail: autofillEventDetail(event),
}, true
case apiaudit.EventAutofillAmbiguous:
return uiAutofillStatus{
Kind: autofillStatusAmbiguous,
Title: "Autofill needs a narrower match",
Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."),
Detail: autofillEventDetail(event),
}, true
case apiaudit.EventAutofillBlocked:
return uiAutofillStatus{
Kind: autofillStatusBlocked,
Title: "Autofill is blocked",
Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."),
Detail: autofillEventDetail(event),
}, true
case apiaudit.EventApprovalAllowed:
if !isAutofillOperation(event.Operation) {
return uiAutofillStatus{}, false
}
return uiAutofillStatus{
Kind: autofillStatusFound,
Title: "Autofill approved",
Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."),
Detail: autofillEventDetail(event),
}, true
case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut:
if !isAutofillOperation(event.Operation) {
return uiAutofillStatus{}, false
}
return uiAutofillStatus{
Kind: autofillStatusBlocked,
Title: "Autofill was not allowed",
Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)),
Detail: autofillEventDetail(event),
}, true
default:
return uiAutofillStatus{}, false
}
}
func autofillEventDetail(event apiaudit.Event) string {
return strings.TrimSpace(resourceDetailText(event.Resource))
}
func resourceDetailText(resource apitokens.Resource) string {
switch resource.Kind {
case apitokens.ResourceEntry:
if entryID := strings.TrimSpace(resource.EntryID); entryID != "" {
return "Entry ID: " + entryID
}
case apitokens.ResourceGroup:
if len(resource.Path) > 0 {
return "Group: " + strings.Join(resource.Path, " / ")
}
}
return ""
}
func formatAutofillRequester(clientName, tokenName string) string {
switch {
case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "":
return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")"
case strings.TrimSpace(clientName) != "":
return strings.TrimSpace(clientName)
case strings.TrimSpace(tokenName) != "":
return strings.TrimSpace(tokenName)
default:
return "A trusted client"
}
}
func defaultAutofillMessage(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string {
switch eventType {
case apiaudit.EventApprovalDenied:
return requester + " was denied for this fill request."
case apiaudit.EventApprovalCanceled:
return requester + " canceled this fill request."
case apiaudit.EventApprovalTimedOut:
return requester + " timed out while waiting for approval."
default:
return requester + " could not fill this target."
}
}
func isAutofillOperation(operation apitokens.Operation) bool {
switch operation {
case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL:
return true
default:
return false
}
}
func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) {
if !u.shouldShowLifecycleSetup() {
if banner.Dismissable {
return "", "Dismiss"
}
return "", ""
}
switch banner.Kind {
case bannerLoading:
if strings.HasPrefix(u.loadingActionLabel, "open ") {
return "Cancel", ""
}
case bannerError:
if u.canRetryLifecycleOpen() {
return "Retry", "Dismiss"
}
if banner.Dismissable {
return "", "Dismiss"
}
}
return "", ""
}
func (u *ui) loadingDetailMessage() string {
if !u.shouldShowLifecycleSetup() {
return ""
}
if u.lifecycleMode == "remote" {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text())
switch {
case baseURL != "" && path != "":
return fmt.Sprintf(
"Target: %s (%s)",
friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}),
path,
)
case baseURL != "":
return "Target: " + baseURL
default:
return "Preparing remote vault access"
}
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return "Preparing local vault access"
}
return "Target: " + path
}
func (u *ui) currentVaultSummary() vaultSummary {
status, ok := u.state.Session.(sessionStatus)
if !ok || !status.HasVault() {
return vaultSummary{}
}
if status.IsRemote() {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text())
summary := vaultSummary{
Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}),
Detail: baseURL,
}
if strings.TrimSpace(summary.Title) == "" {
summary.Title = "Remote vault"
}
summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path))
return summary
}
path := strings.TrimSpace(u.vaultPath.Text())
summary := vaultSummary{
Title: friendlyRecentVaultLabel(path),
Detail: path,
}
if strings.TrimSpace(summary.Title) == "" {
summary.Title = "Local vault"
}
summary.Context = u.vaultResumeContext(u.recentVaultGroup(path))
return summary
}
func (u *ui) vaultResumeContext(path []string) string {
if len(path) == 0 {
return ""
}
displayPath := append([]string(nil), path...)
if len(displayPath) == 0 {
return ""
}
return "Resume in: " + strings.Join(displayPath, " / ")
}
func compactPathDirectorySummary(path string) string {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "." || cleaned == "" {
return ""
}
dir := filepath.Dir(cleaned)
if dir == "." || dir == cleaned {
return ""
}
if dir == string(filepath.Separator) {
return dir
}
parts := strings.Split(filepath.ToSlash(dir), "/")
filtered := parts[:0]
for _, part := range parts {
if strings.TrimSpace(part) != "" {
filtered = append(filtered, part)
}
}
parts = filtered
if len(parts) <= 2 {
return filepath.ToSlash(dir)
}
return parts[0] + "/.../" + parts[len(parts)-1]
}
func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) {
if !u.requestMasterPassFocus {
return
}
gtx.Execute(key.FocusCmd{Tag: &u.masterPassword})
gtx.Execute(op.InvalidateCmd{})
u.requestMasterPassFocus = false
}
func (u *ui) sessionSurface() uiSurface {
if u.state.Session == nil {
return uiSurface{}
}
if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) {
return uiSurface{
Title: "Vault locked",
Message: "Enter a master password, choose a key file, or provide both to unlock the vault.",
Locked: true,
}
}
return uiSurface{}
}
func (u *ui) hasOpenVault() bool {
status, ok := u.state.Session.(sessionStatus)
if ok {
return status.HasVault()
}
_, err := u.state.Session.Current()
return err == nil
}
func (u *ui) isVaultLocked() bool {
status, ok := u.state.Session.(sessionStatus)
if ok {
return status.IsLocked()
}
_, err := u.state.Session.Current()
return errors.Is(err, session.ErrLocked)
}
func (u *ui) shouldShowLifecycleSetup() bool {
return !u.hasOpenVault()
}
func (u *ui) lifecycleBusy() bool {
return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != ""
}
func (u *ui) shouldUseLockedSinglePane() bool {
return u.isVaultLocked() && !u.shouldShowLifecycleSetup()
}
func (u *ui) shouldShowDesktopWorkingHeader() bool {
return u.mode == "desktop" && !u.shouldShowLifecycleSetup() && !u.isVaultLocked()
}
func (u *ui) shouldUseCompactPhoneDetailPane() bool {
if u.mode != "phone" {
return false
}
if u.isVaultLocked() || u.editingEntry {
return false
}
_, ok := u.selectedEntry()
return !ok
}
func (u *ui) chooseExistingFileAction(target *widget.Editor) error {
path, err := pickExistingFile()
if err != nil {
return err
}
target.SetText(path)
return nil
}
func (u *ui) listEmptyMessage() string {
return u.listEmptyState().Body
}
func (u *ui) listEmptyState() emptyState {
if surface := u.sessionSurface(); surface.Locked {
return emptyState{
Title: "Vault locked",
Body: "Unlock the vault to browse entries and groups.",
}
}
query := strings.TrimSpace(u.search.Text())
if query != "" {
switch u.state.Section {
case appstate.SectionAPITokens:
return emptyState{
Title: "No matching API tokens",
Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search 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.mode != "phone" {
return
}
u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0
}
func (u *ui) setCurrentPath(path []string) {
u.currentPath = append([]string(nil), path...)
u.state.NavigateToPath(path)
u.syncedPath = append([]string(nil), path...)
u.syncPhoneGroupBrowser(path)
u.noteCurrentVaultPath()
u.clearDeleteGroupConfirmation()
}
func (u *ui) syncCurrentPath() {
switch {
case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
u.currentPath = append([]string(nil), u.state.CurrentPath...)
case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath):
u.state.CurrentPath = append([]string(nil), u.currentPath...)
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.syncedPath = append([]string(nil), u.currentPath...)
u.noteCurrentVaultPath()
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
u.clearDeleteGroupConfirmation()
}
}
func (u *ui) noteCurrentVaultPath() {
status, ok := u.state.Session.(sessionStatus)
if !ok || status.IsLocked() {
return
}
if status.IsRemote() {
u.noteCurrentRemotePath()
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return
}
if u.recentVaultGroups == nil {
u.recentVaultGroups = map[string][]string{}
}
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
u.saveRecentVaults()
}
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
// Clear the full frame explicitly so mobile surfaces don't start from an
// unpainted black buffer before nested background widgets run.
paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op())
u.syncHostedAPI()
u.filter()
u.processShortcuts(gtx)
for u.createVault.Clicked(gtx) {
u.runAction("create vault", u.createVaultAction)
}
for u.openVault.Clicked(gtx) {
u.startOpenVaultAction()
}
for u.lifecycleRemoteSyncAction.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.beginLifecycleRemoteSyncOpen()
}
for u.saveVault.Clicked(gtx) {
u.runAction("save vault", u.saveAction)
}
for u.saveAsVault.Clicked(gtx) {
u.runAction("save-as vault", u.saveAsAction)
}
for u.openRemote.Clicked(gtx) {
u.startOpenRemoteAction()
}
for u.changeMasterKey.Clicked(gtx) {
u.runAction("change master key", u.changeMasterKeyAction)
}
for u.synchronizeVault.Clicked(gtx) {
u.runAction("synchronize vault", u.synchronizeAction)
}
for u.toggleSyncMenu.Clicked(gtx) {
u.syncMenuOpen = !u.syncMenuOpen
}
for u.toggleMainMenu.Clicked(gtx) {
u.mainMenuOpen = !u.mainMenuOpen
}
for u.openAdvancedSync.Clicked(gtx) {
u.openAdvancedSyncDialog()
}
for u.openSecuritySettings.Clicked(gtx) {
u.loadSecuritySettingsFromSession()
u.loadSettingsFormFromPreferences()
u.loadSettingsDraft()
u.mainMenuOpen = false
u.securityDialogOpen = true
}
for u.openRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = true
}
for u.setStatusBannerShort.Clicked(gtx) {
u.setStatusBannerTTL(2 * time.Second)
}
for u.setStatusBannerStandard.Clicked(gtx) {
u.setStatusBannerTTL(statusBannerDuration)
}
for u.setStatusBannerLong.Clicked(gtx) {
u.setStatusBannerTTL(statusBannerLong)
}
for u.showAllAutofillNotices.Clicked(gtx) {
u.setAutofillNoticePreference(autofillNoticeAll)
}
for u.showApprovalAutofillOnly.Clicked(gtx) {
u.setAutofillNoticePreference(autofillNoticeApprovals)
}
for u.hideAutofillNotices.Clicked(gtx) {
u.setAutofillNoticePreference(autofillNoticeSuppressed)
}
for u.closeAdvancedSync.Clicked(gtx) {
u.syncDialogOpen = false
u.showSyncPassword = false
}
for u.closeSecuritySettings.Clicked(gtx) {
u.securityDialogOpen = false
}
for u.closeRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = false
}
for u.runAdvancedSync.Clicked(gtx) {
u.runAction("advanced synchronize vault", u.advancedSyncAction)
}
for u.saveSecuritySettings.Clicked(gtx) {
u.runAction("save settings", u.saveSecuritySettingsAction)
}
for u.settingsDensityDense.Clicked(gtx) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense
_ = 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()
}
for u.unlockVault.Clicked(gtx) {
u.startUnlockAction()
}
for u.cancelLifecycleProgress.Clicked(gtx) {
u.cancelLifecycleBusyState()
}
for u.retryLifecycleOpen.Clicked(gtx) {
u.state.ErrorMessage = ""
u.retryLastLifecycleOpen()
}
for u.showEntries.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showEntriesSection()
}
for u.showTemplates.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showTemplatesSection()
}
for u.showRecycle.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showRecycleBinSection()
}
for u.showAPITokens.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showAPITokensSection()
}
for u.showAPIAudit.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showAPIAuditSection()
}
for u.showAbout.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showAboutSection()
}
for u.showLocalLifecycle.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.lifecycleMode = "local"
u.requestMasterPassFocus = true
}
for u.showRemoteLifecycle.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.lifecycleMode = "remote"
u.selectedRemoteConnection = false
u.requestMasterPassFocus = true
}
for u.toggleLifecycleAdvanced.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden
u.saveUIPreferences()
}
for u.showSyncLocal.Clicked(gtx) {
u.syncSourceMode = syncSourceLocal
}
for u.showSyncRemote.Clicked(gtx) {
u.syncSourceMode = syncSourceRemote
}
for u.showSyncPull.Clicked(gtx) {
u.syncDirection = syncDirectionPull
}
for u.showSyncPush.Clicked(gtx) {
u.syncDirection = syncDirectionPush
}
for u.showSettingsSyncLocal.Clicked(gtx) {
u.settingsDraft.Sync.SourceDefault = syncSourceLocal
_ = 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()
}
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()
}
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 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) })
}
}
for u.copyAPITokenSecret.Clicked(gtx) {
secret := u.apiTokenSecret
u.runAction("copy API token secret", func() error {
if strings.TrimSpace(secret) == "" {
return fmt.Errorf("no API token secret to copy")
}
if u.clipboardWriter != nil {
return u.clipboardWriter.WriteText(secret)
}
return clipboard.WriteText(secret)
})
}
for u.editEntry.Clicked(gtx) {
u.editingEntry = true
u.loadSelectedEntryIntoEditor()
}
for u.cancelEdit.Clicked(gtx) {
u.editingEntry = false
u.loadSelectedEntryIntoEditor()
}
for u.pickVaultPath.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.startChooseVaultPathAction()
}
for u.importSharedVault.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.startImportSharedVaultAction()
}
for u.pickKeyFile.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
}
for u.pickSyncLocalPath.Clicked(gtx) {
u.startChooseSyncLocalSourceAction()
}
for i := range u.recentVaultClicks {
for u.recentVaultClicks[i].Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
if i < len(u.recentVaults) {
u.lifecycleMode = "local"
u.vaultPath.SetText(u.recentVaults[i])
u.requestMasterPassFocus = true
}
}
}
for i := range u.recentRemoteClicks {
for u.recentRemoteClicks[i].Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
if i < len(u.recentRemotes) {
u.lifecycleMode = "remote"
u.applyRecentRemoteRecord(u.recentRemotes[i])
u.requestMasterPassFocus = true
}
}
}
for 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])
}
}
}
for u.useSavedAdvancedSyncRemote.Clicked(gtx) {
u.openRemoteSyncSetupDialog()
}
for u.openSelectedVaultRemote.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
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)
}
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
}
for u.dismissBanner.Clicked(gtx) {
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
}
for u.addEntry.Clicked(gtx) {
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.editingEntry = true
}
for u.saveEntry.Clicked(gtx) {
u.runAction("save entry", u.saveEntryAction)
}
for u.duplicateEntry.Clicked(gtx) {
u.runAction("duplicate entry", u.duplicateSelectedEntryAction)
}
for u.deleteEntry.Clicked(gtx) {
u.runAction("delete entry", u.deleteSelectedEntryAction)
}
for u.restoreEntry.Clicked(gtx) {
u.runAction("restore entry", u.restoreSelectedRecycleEntryAction)
}
for u.saveTemplate.Clicked(gtx) {
u.runAction("save template", u.saveTemplateAction)
}
for u.deleteTemplate.Clicked(gtx) {
u.runAction("delete template", u.deleteSelectedTemplateAction)
}
for u.instantiateTemplate.Clicked(gtx) {
u.runAction("instantiate template", u.instantiateSelectedTemplateAction)
}
for u.addAttachment.Clicked(gtx) {
u.runAction("add attachment", u.addAttachmentAction)
}
for u.replaceAttachment.Clicked(gtx) {
u.runAction("replace attachment", u.replaceAttachmentAction)
}
for u.removeAttachment.Clicked(gtx) {
u.runAction("remove attachment", u.removeAttachmentAction)
}
for u.exportAttachment.Clicked(gtx) {
u.runAction("export attachment", u.exportAttachmentAction)
}
for u.copyUser.Clicked(gtx) {
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
}
for u.copyPass.Clicked(gtx) {
u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) })
}
for u.copyURL.Clicked(gtx) {
u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) })
}
for u.generatePassword.Clicked(gtx) {
u.runAction("generate password", u.generatePasswordAction)
}
for u.restoreHistory.Clicked(gtx) {
u.runAction("restore history", u.restoreSelectedHistoryAction)
}
for u.createGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("create group", u.createGroupAction)
}
for u.moveGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("move group", u.moveCurrentGroupAction)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
}
for u.toggleGroupControls.Clicked(gtx) {
u.groupControlsHidden = !u.groupControlsHidden
u.saveUIPreferences()
}
for u.toggleHistory.Clicked(gtx) {
u.historyHidden = !u.historyHidden
u.saveUIPreferences()
}
for u.renameGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("rename group", u.renameGroupAction)
}
for u.deleteGroup.Clicked(gtx) {
u.armDeleteCurrentGroupAction()
}
for u.confirmDeleteGroup.Clicked(gtx) {
u.runAction("delete group", u.deleteCurrentGroupAction)
u.clearDeleteGroupConfirmation()
}
for u.cancelDeleteGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
}
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()
}
inset := layout.UniformInset(unit.Dp(16))
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(u.header),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.bannerSurface().Kind == bannerNone {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(u.banner),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.bannerSurface().Kind != bannerNone {
return layout.Dimensions{}
}
if u.autofillStatusSurface().Kind == autofillStatusNone {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(u.autofillStatusCard),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return u.lifecycleScreen(gtx)
}
if u.shouldUseLockedSinglePane() {
return u.detailPanel(gtx)
}
if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) {
u.phoneSpan = gtx.Constraints.Max.Y
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value)
if listHeight < gtx.Dp(unit.Dp(180)) {
listHeight = gtx.Dp(unit.Dp(180))
}
if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) {
listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = listHeight
gtx.Constraints.Max.Y = listHeight
return u.listPanel(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(u.phoneSlider),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
func() layout.FlexChild {
if u.shouldUseCompactPhoneDetailPane() {
return layout.Rigid(u.detailPanel)
}
return layout.Flexed(1, u.detailPanel)
}(),
)
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(0.38, u.listPanel),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(0.62, u.detailPanel),
)
}),
)
})
})
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if !u.syncDialogOpen {
return layout.Dimensions{}
}
return u.syncDialog(gtx)
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if !u.securityDialogOpen {
return layout.Dimensions{}
}
return u.securityDialog(gtx)
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if !u.remotePrefsDialogOpen {
return layout.Dimensions{}
}
return u.remotePrefsDialog(gtx)
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if _, ok := u.pendingApproval(); !ok {
return layout.Dimensions{}
}
return u.approvalDialog(gtx)
}),
layout.Stacked(u.statusToast),
)
}
func (u *ui) syncHostedAPI() {
if u.apiHost == nil {
return
}
if err := u.apiHost.SyncFromLifecycle(); err != nil {
u.state.ErrorMessage = fmt.Sprintf("sync gRPC API: %v", err)
}
}
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card
if u.mode == "phone" {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
u.lifecycleBranding,
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.lifecycleControls,
}
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(620))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.syncDialogContent)
})
}),
)
}
func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(620))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.securityDialogContent)
})
}),
)
}
func (u *ui) remotePrefsDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(660))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.remotePrefsDialogContent)
})
}),
)
}
func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "Settings")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "ACCESSIBILITY")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return u.settingsPreferenceCard(gtx, "Display Density", "Adjust editor height and control spacing for denser or roomier forms.", func(gtx layout.Context) layout.Dimensions {
return u.settingsChoiceRow(
gtx,
choiceSpec{Click: &u.settingsDensityDense, Label: "Dense", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense},
choiceSpec{Click: &u.settingsDensityComfortable, Label: "Comfortable", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityComfortable},
)
})
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return u.settingsPreferenceCard(gtx, "Contrast", "Increase focus and selection contrast without changing unrelated vault behavior.", func(gtx layout.Context) layout.Dimensions {
return u.settingsChoiceRow(
gtx,
choiceSpec{Click: &u.settingsContrastStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.Contrast == contrastStandard},
choiceSpec{Click: &u.settingsContrastHigh, Label: "High Contrast", Active: u.settingsDraft.Accessibility.Contrast == contrastHigh},
)
})
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return u.settingsPreferenceCard(gtx, "Reduced Motion", "Keep transient status toasts steady instead of auto-dismissing after a short timeout.", func(gtx layout.Context) layout.Dimensions {
return u.settingsChoiceRow(
gtx,
choiceSpec{Click: &u.settingsReducedMotionOff, Label: "Off", Active: !u.settingsDraft.Accessibility.ReducedMotion},
choiceSpec{Click: &u.settingsReducedMotionOn, Label: "On", Active: u.settingsDraft.Accessibility.ReducedMotion},
)
})
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return u.settingsPreferenceCard(gtx, "Keyboard Focus", "Strengthen the visible focus ring and focused selection treatment for keyboard-first navigation.", func(gtx layout.Context) layout.Dimensions {
return u.settingsChoiceRow(
gtx,
choiceSpec{Click: &u.settingsKeyboardFocusStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusStandard},
choiceSpec{Click: &u.settingsKeyboardFocusProminent, Label: "Prominent", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusProminent},
)
})
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, sync defaults, and KDBX security defaults without crowding the main vault flow.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "UI Preferences")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsGroupControls, "Keep Group Tools collapsed")
return check.Layout(gtx)
},
func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsLifecycleAdvanced, "Keep advanced lifecycle controls collapsed")
return check.Layout(gtx)
},
func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed")
return check.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Vault Security")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false),
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false),
layout.Spacer{Height: unit.Dp(12)}.Layout,
syncDialogSectionLabel(u.theme, "Sync Defaults"),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "Advanced Sync starts from these defaults. You can still change the source or direction before a single run.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncPull, "Pull Into Current Vault", u.settingsDraft.Sync.DirectionDefault == syncDirectionPull)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncPush, "Push Current Vault Out", u.settingsDraft.Sync.DirectionDefault == syncDirectionPush)
}),
)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncLocal, "Local File", u.settingsDraft.Sync.SourceDefault == syncSourceLocal)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncRemote, "Remote WebDAV", u.settingsDraft.Sync.SourceDefault == syncSourceRemote)
}),
)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Conflict handling stays retry-safe: merged entry changes keep history, while remote save conflicts still require reopening the vault and retrying the save.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
syncDialogSectionLabel(u.theme, "Background Sync"),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Future background sync controls belong here so source, direction, and unattended behavior stay in one settings surface.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
syncDialogSectionLabel(u.theme, "Feedback"),
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Success and reminder banners")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Choose how long noncritical status banners stay visible.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.setStatusBannerShort, "Short", u.statusBannerTTL == 2*time.Second)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.setStatusBannerStandard, "Standard", u.statusBannerTTL == statusBannerDuration)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.setStatusBannerLong, "Long", u.statusBannerTTL == statusBannerLong)
}),
)
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Autofill notices")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Keep recent autofill results visible, reduce them to approval-only, or hide them.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showAllAutofillNotices, "All", u.autofillNoticePreference == autofillNoticeAll)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showApprovalAutofillOnly, "Approval Only", u.autofillNoticePreference == autofillNoticeApprovals)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.hideAutofillNotices, "Hidden", u.autofillNoticePreference == autofillNoticeSuppressed)
}),
)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
syncDialogSectionLabel(u.theme, "Privacy"),
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return settingsSummaryCard(gtx, u.theme, "PRIVACY PLAN", "Use first-fill approval plus browser/app rules to keep autofill constrained to trusted targets.")
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "First-fill approval")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), u.autofillFirstFillApprovalSummary())
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAsk, "Ask First", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAsk)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAllow, "Allow First Fill", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAllow)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalBlock, "Block Until Allowed", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalBlock)
}),
)
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d autofill rule entries configured across browsers, apps, and package-specific overrides.", u.autofillRuleCount()))
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditorHelp(u.theme, "Browser allowlist", "One origin or hostname per line for trusted browser surfaces.", &u.autofillBrowserAllowlist, false),
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditorHelp(u.theme, "App and package allowlist", "One Android package name or trusted app identifier per line.", &u.autofillAppAllowlist, false),
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditorHelp(u.theme, "Package rules", "One rule per line, for example `com.android.chrome=hostname` or `org.keepassgo.browser=view-id`.", &u.autofillPackageRules, false),
func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Close")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Settings")
}),
)
},
}
return material.List(u.theme, &u.securityDialogList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "Remote Connection Settings & Help")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Use Recent Connections to reopen WebDAV-backed vaults quickly without cluttering the main open flow.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Current", u.remotePreferencesCurrentSummary(), "")(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Always Saved", u.remotePreferencesAlwaysSavedSummary(), "")(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Retention", u.remotePreferencesRetentionSummary(), "")(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "How Persistence Works", u.remotePreferencesPersistenceSummary(), "")(gtx)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeRemotePrefsHelp, "Done")
},
}
return material.List(u.theme, &u.remotePrefsDialogList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 110}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(640))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.approvalDialogContent)
})
}),
)
}
func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
request, ok := u.pendingApproval()
if !ok {
return layout.Dimensions{}
}
resourceText := approvalResourceText(request)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "Approve API Request")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "An external tool requested vault access that is not explicitly allowed or denied.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Client", strings.TrimSpace(request.ClientName), strings.TrimSpace(request.TokenName))(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent")
check.Color = accentColor
return check.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
}),
)
}),
)
}
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
}
return material.List(u.theme, &u.syncDialogList).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), u.syncDialogTitle())
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), u.syncDialogDescription())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.syncSourceMode == syncSourceRemote {
children := []layout.FlexChild{
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncPasswordField(gtx)
}),
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
check.Color = accentColor
return check.Layout(gtx)
}),
)
}
if len(matchingCredentials) > 0 {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
for i, entry := range matchingCredentials {
i := i
entry := entry
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := entry.Title
if strings.TrimSpace(entry.Username) != "" {
label += " · " + strings.TrimSpace(entry.Username)
}
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
}),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
children...,
)
}
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
}
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
}),
)
}),
)
})
}
func (u *ui) pendingApproval() (apiapproval.Request, bool) {
pending := u.state.PendingApprovals()
if len(pending) == 0 {
return apiapproval.Request{}, false
}
return pending[0], true
}
func (u *ui) resolvePendingApproval(outcome apiapproval.Outcome) error {
request, ok := u.pendingApproval()
if !ok {
return fmt.Errorf("no pending approval")
}
return u.state.ResolveApproval(request.ID, outcome)
}
func approvalResourceText(request apiapproval.Request) string {
switch request.Resource.Kind {
case apitokens.ResourceEntry:
if strings.TrimSpace(request.Resource.EntryID) != "" {
return "Entry " + request.Resource.EntryID
}
case apitokens.ResourceGroup:
if len(request.Resource.Path) > 0 {
return strings.Join(request.Resource.Path, " / ")
}
}
return "Vault root"
}
func approvalFact(theme *material.Theme, title, primary, secondary string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(12), strings.ToUpper(title))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(16), strings.TrimSpace(primary))
lbl.Color = theme.Palette.Fg
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(secondary) == "" {
return layout.Dimensions{}
}
lbl := material.Label(theme, unit.Sp(13), strings.TrimSpace(secondary))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
}
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
field := func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.outlinedFieldState(gtx, false, field)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.headerActions(gtx)
}),
)
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.brandMark(gtx, 196, 56)
}),
layout.Rigid(u.headerActions),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
if u.isVaultLocked() {
return layout.Dimensions{}
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(u.mainMenuButtonGroup),
)
}
if u.mode == "phone" {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Rigid(row),
)
}
return row(gtx)
}
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAbout, "About")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
}),
)
})
}
func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(22), "KeePassGO")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), "A KeePass-compatible password manager built in Go.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
aboutFact(u.theme, "Compatibility", "KeePass and KDBX interoperability", "Designed to coexist with desktop KeePass and KeePass2Android workflows."),
layout.Spacer{Height: unit.Dp(10)}.Layout,
aboutFact(u.theme, "Platforms", "Windows and Linux first, Android supported", "Desktop remains the primary product surface while Android stays compatible."),
layout.Spacer{Height: unit.Dp(10)}.Layout,
aboutFact(u.theme, "Sync", "Local files and direct WebDAV", "Remote-file workflows are first-class and avoid browser-stack dependencies."),
layout.Spacer{Height: unit.Dp(10)}.Layout,
aboutFact(u.theme, "Programmatic Access", "Secure local gRPC API", "Built for trusted clients such as browser extensions and automation."),
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Version")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), currentAppVersion())
lbl.Color = u.theme.Palette.Fg
return lbl.Layout(gtx)
},
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func aboutFact(theme *material.Theme, title, primary, secondary string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, 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(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), primary)
lbl.Color = theme.Palette.Fg
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(13), secondary)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
}
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
label := "Sync"
spacing := unit.Dp(4)
if u.mode == "phone" {
spacing = unit.Dp(3)
}
row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone")
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncMenuToggle(gtx)
}),
)
}
rowDims := row(gtx)
if !u.syncMenuOpen {
return rowDims
}
menuGTX := gtx
menuGTX.Constraints.Min = image.Point{}
menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.syncMenu)
menuCall := menuOps.Stop()
menuX := anchoredMenuX(rowDims.Size.X, menuDims.Size.X)
stack := op.Offset(image.Pt(menuX, rowDims.Size.Y)).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop()
return rowDims
}
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.mode == "phone" {
btn.Size = unit.Dp(16)
btn.Inset = layout.UniformInset(unit.Dp(7))
}
return btn.Layout(gtx)
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
profiles := u.availableRemoteProfiles()
credentials := u.availableRemoteCredentialEntries()
if len(u.vaultRemoteProfileClicks) < len(profiles) {
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
}
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !supportsVaultShare(runtime.GOOS) || u.vaultSharer == nil || strings.TrimSpace(u.currentShareableVaultPath()) == "" {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
}),
}
if u.shouldShowRemoteSyncSetupShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel())
}),
)
}
if u.shouldShowDirectRemoteSyncShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel())
}),
)
}
if u.shouldShowRemoteSyncSettingsShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel())
}),
)
}
if u.shouldShowRemoveRemoteSyncShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel())
}),
)
}
if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.savedRemoteBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
if !u.shouldShowSavedRemoteBindingSelectors() {
rows = append(rows,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary()
if !ok {
return layout.Dimensions{}
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profileLabel)
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), "Credential: "+credentialLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), syncLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}),
)
} else {
for i, profile := range profiles {
i := i
profile := profile
rows = append(rows,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := strings.TrimSpace(u.selectedVaultRemoteProfileID) == profile.ID
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}),
)
}
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
for i, entry := range credentials {
i := i
entry := entry
rows = append(rows,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == entry.ID
label := entry.Title
if strings.TrimSpace(entry.Username) != "" {
label += " · " + strings.TrimSpace(entry.Username)
}
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}),
)
}
}
if _, ok := u.selectedVaultRemoteProfile(); ok {
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
}),
)
}
}
}
if u.hasOpenVault() {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
remotePath := strings.TrimSpace(u.remotePath.Text())
username := strings.TrimSpace(u.remoteUsername.Text())
password := u.remotePassword.Text()
if baseURL != "" && remotePath != "" && username != "" && password != "" {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.saveCurrentRemoteBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, u.saveCurrentRemoteBindingButtonLabel())
}),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
})
}
func (u *ui) sectionSpacing() unit.Dp {
if u.mode == "phone" {
if u.denseLayout {
return unit.Dp(4)
}
return unit.Dp(6)
}
if u.denseLayout {
return unit.Dp(8)
}
return unit.Dp(12)
}
func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, unit.Dp) {
inset := unit.Dp(12)
titleSize := unit.Sp(17)
metaSize := unit.Sp(14)
urlSize := unit.Sp(12)
pathSize := unit.Sp(11)
dividerGap := unit.Dp(7)
if u.denseLayout {
inset = unit.Dp(9)
titleSize = unit.Sp(15)
metaSize = unit.Sp(12)
urlSize = unit.Sp(11)
pathSize = unit.Sp(10)
dividerGap = unit.Dp(5)
}
if u.mode == "phone" {
inset = unit.Dp(9)
titleSize = unit.Sp(15)
metaSize = unit.Sp(12)
urlSize = unit.Sp(11)
pathSize = unit.Sp(10)
dividerGap = unit.Dp(5)
if u.denseLayout {
inset = unit.Dp(8)
titleSize = unit.Sp(14)
metaSize = unit.Sp(11)
urlSize = unit.Sp(10)
pathSize = unit.Sp(9)
dividerGap = unit.Dp(4)
}
}
return inset, titleSize, metaSize, urlSize, pathSize, dividerGap
}
func (u *ui) listPanelTopSections() []listPanelTopSection {
sections := make([]listPanelTopSection, 0, 6)
if u.state.Section != appstate.SectionAbout {
sections = append(sections, listPanelTopSearch)
}
if !u.isVaultLocked() {
sections = append(sections, listPanelTopNavigation)
}
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
sections = append(sections, listPanelTopPath)
}
if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries {
sections = append(sections, listPanelTopGroup, listPanelTopGroupTools)
}
if !u.isVaultLocked() {
sections = append(sections, listPanelTopPrimary)
}
return sections
}
func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
return layout.Dimensions{}
}
if u.mode == "phone" {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
}
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
})
}
func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
return 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{}
}
}
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
panel := card
spacing := u.sectionSpacing()
if u.mode == "phone" {
panel = compactCard
}
u.ensureNavClickables()
if u.mode == "phone" {
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
visibleEntries, entryClicks := u.visibleEntrySnapshot()
rows := make([]layout.Widget, 0, 16+len(visibleEntries))
for _, section := range u.listPanelTopSections() {
switch section {
case listPanelTopSearch:
rows = append(rows, u.listPanelSearchRow)
case listPanelTopNavigation:
rows = append(rows, u.navigationHeader)
case listPanelTopPath:
rows = append(rows, u.pathBar)
case listPanelTopGroup:
rows = append(rows, u.groupBar)
case listPanelTopGroupTools:
rows = append(rows, u.groupControlsSection)
case listPanelTopPrimary:
rows = append(rows, u.listPanelPrimaryActionRow)
}
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: spacing}.Layout(gtx)
})
}
switch {
case u.state.Section == appstate.SectionAPITokens:
rows = append(rows, u.apiTokenListPanel)
case u.state.Section == appstate.SectionAPIAudit:
rows = append(rows, u.apiAuditListPanel)
case u.state.Section == appstate.SectionAbout:
case len(visibleEntries) == 0:
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
})
default:
for i := range visibleEntries {
idx := i
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return u.entryRow(gtx, entryClicks[idx], idx, visibleEntries[idx])
})
}
}
return material.List(u.theme, &u.phonePanelList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
children := make([]layout.FlexChild, 0, 16)
for _, section := range u.listPanelTopSections() {
switch section {
case listPanelTopSearch:
children = append(children, layout.Rigid(u.listPanelSearchRow))
case listPanelTopNavigation:
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
return layout.Dimensions{}
}
return u.navigationHeader(gtx)
}))
case listPanelTopPath:
children = append(children, 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)
}))
case listPanelTopGroup:
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
return u.groupBar(gtx)
}))
case listPanelTopGroupTools:
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
return u.groupControlsSection(gtx)
}))
case listPanelTopPrimary:
children = append(children, layout.Rigid(u.listPanelPrimaryActionRow))
}
children = append(children, layout.Rigid(layout.Spacer{Height: spacing}.Layout))
}
children = append(children,
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 u.state.Section == appstate.SectionAbout {
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
}
if len(u.visible) == 0 {
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
}
return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions {
item := u.visible[i]
click := &u.entryClicks[i]
return u.entryRow(gtx, click, i, item)
})
}),
)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout {
return layout.Dimensions{}
}
if u.state.Section == appstate.SectionAbout {
lbl := material.Label(u.theme, unit.Sp(18), "About")
lbl.Color = accentColor
return lbl.Layout(gtx)
}
return u.groupControlsDisclosure(gtx)
}
func (u *ui) navigationHeaderLabel() string {
if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout {
return ""
}
if u.state.Section == appstate.SectionAbout {
return "About"
}
return "Group Tools"
}
func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
tabs := []struct {
click *widget.Clickable
label string
compact string
active bool
}{
{click: &u.showEntries, label: "Entries", compact: "Entries", active: u.state.Section == appstate.SectionEntries},
{click: &u.showRecycle, label: "Recycle Bin", compact: "Recycle", active: u.state.Section == appstate.SectionRecycleBin},
{click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens},
{click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit},
}
if u.mode == "phone" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].compact, tabs[0].active)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].compact, tabs[1].active)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].compact, tabs[2].active)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].compact, tabs[3].active)
}),
)
}),
)
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].label, tabs[0].active)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].label, tabs[1].active)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].label, tabs[2].active)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].label, tabs[3].active)
}),
)
}
func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions {
for click.Clicked(gtx) {
_ = u.state.ToggleVisibleIndex(idx)
u.loadSelectedEntryIntoEditor()
}
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics()
selected := item.ID == u.state.SelectedEntryID
focused := u.isFocused(listFocusID(idx))
rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin)
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin
hasUsername := strings.TrimSpace(item.Username) != ""
hasURL := strings.TrimSpace(item.URL) != ""
pathText := strings.Join(u.displayEntryPath(item.Path), " / ")
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, titleSize, item.Title)
lbl.Color = rowColors.Title
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasUsername {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasUsername {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, metaSize, item.Username)
lbl.Color = rowColors.Meta
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasURL {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasURL {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, urlSize, item.URL)
lbl.Color = rowColors.Secondary
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showPath {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showPath {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, pathSize, pathText)
lbl.Color = rowColors.Secondary
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: dividerGap}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
w := gtx.Constraints.Max.X
if w < 1 {
w = 1
}
paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op())
return layout.Dimensions{Size: image.Pt(w, 1)}
}),
)
})
}
if selected || focused {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op())
paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return row(gtx)
}),
)
}
bg := panelColor
if u.state.Section == appstate.SectionRecycleBin {
bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255}
}
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
return row(gtx)
})
})
}
func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions {
if u.mode != "phone" {
return layout.Dimensions{}
}
for {
e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical)
if !ok {
break
}
switch e.Kind {
case pointer.Press:
u.splitBase = u.phoneSplit.Value
u.splitStartY = e.Position.Y
case pointer.Drag:
if u.phoneSpan > 0 {
next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan)
if next < 0.28 {
next = 0.28
}
if next > 0.72 {
next = 0.72
}
u.phoneSplit.Value = next
}
}
}
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(24))
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
u.splitDrag.Add(gtx.Ops)
pointer.CursorRowResize.Add(gtx.Ops)
handleW := gtx.Dp(unit.Dp(108))
handleH := gtx.Dp(unit.Dp(6))
x := (gtx.Constraints.Min.X - handleW) / 2
y := (gtx.Constraints.Min.Y - handleH) / 2
paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+2), Max: image.Pt(gtx.Constraints.Min.X, y+3)}.Op())
paint.FillShape(gtx.Ops, accentColor, clip.RRect{
Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)},
NE: 2, NW: 2, SE: 2, SW: 2,
}.Op(gtx.Ops))
return layout.Dimensions{Size: gtx.Constraints.Min}
})
}
func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
panel := card
if u.mode == "phone" {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
if u.shouldShowDesktopWorkingHeader() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{}
}),
layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(u.mainMenuButtonGroup),
)
}),
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),
}
}
if u.state.Section == appstate.SectionAbout {
return []layout.FlexChild{
layout.Flexed(1, u.aboutDetailPanel),
}
}
item, ok := u.selectedEntry()
if !ok && !u.editingEntry {
return []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(18), "Entry details")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), u.detailPlaceholderMessage())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
}
}
if u.editingEntry {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
title := "New Entry"
if ok {
title = "Edit Entry"
}
lbl := material.Label(u.theme, unit.Sp(18), title)
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.entryEditorPanel,
}
return []layout.FlexChild{
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}),
}
}
password := u.detailPasswordValue()
titleSize := unit.Sp(26)
titlePad := unit.Dp(10)
sectionGap := unit.Dp(6)
cardGap := unit.Dp(8)
if u.denseLayout {
titlePad = unit.Dp(6)
sectionGap = unit.Dp(4)
cardGap = unit.Dp(6)
}
if u.mode == "phone" {
titleSize = unit.Sp(18)
titlePad = unit.Dp(4)
sectionGap = unit.Dp(4)
cardGap = unit.Dp(6)
if u.denseLayout {
titlePad = unit.Dp(3)
sectionGap = unit.Dp(3)
cardGap = unit.Dp(4)
}
}
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
title := item.Title
if u.state.Section == appstate.SectionRecycleBin {
title = "Recycle Bin Entry"
}
lbl := material.Label(u.theme, titleSize, title)
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: titlePad}.Layout,
func(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionRecycleBin {
if u.mode == "phone" {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions {
return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions {
return compactTonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
}),
)
}
return layout.Dimensions{}
}
return recycleDetailTitle(gtx, u.theme, item.Title)
},
layout.Spacer{Height: func() unit.Dp {
if u.state.Section == appstate.SectionRecycleBin {
return unit.Dp(10)
}
return 0
}()}.Layout,
func(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionRecycleBin {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "This entry is in the recycle bin. Review it, copy from it, or restore it back into the vault.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
},
layout.Spacer{Height: func() unit.Dp {
if u.state.Section == appstate.SectionRecycleBin {
return unit.Dp(8)
}
return 0
}()}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / "))),
layout.Rigid(layout.Spacer{Height: sectionGap}.Layout),
layout.Rigid(detailLine(u.theme, "Username", item.Username)),
layout.Rigid(layout.Spacer{Height: sectionGap}.Layout),
layout.Rigid(detailLine(u.theme, "URL", item.URL)),
layout.Rigid(layout.Spacer{Height: sectionGap}.Layout),
layout.Rigid(detailLine(u.theme, "Tags", strings.Join(item.Tags, ", "))),
)
})
},
layout.Spacer{Height: sectionGap}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(u.passwordLine("Password", password)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL")
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL")
}),
)
}),
)
})
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "NOTES")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(u.theme, item.Notes)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
},
layout.Spacer{Height: cardGap}.Layout,
u.attachmentSummaryPanel,
layout.Spacer{Height: cardGap}.Layout,
u.historyPanel,
layout.Spacer{Height: cardGap}.Layout,
func(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template")
}),
)
case appstate.SectionRecycleBin:
return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry To Vault")
default:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.editEntry, "Edit")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete")
}),
)
}
},
}
return []layout.FlexChild{
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}),
}
}()...)
}
func (u *ui) banner(gtx layout.Context) layout.Dimensions {
banner := u.bannerSurface()
if banner.Kind == bannerNone {
return layout.Dimensions{}
}
bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255}
fg := accentColor
switch banner.Kind {
case bannerLoading:
bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255}
fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255}
case bannerError:
bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255}
fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255}
}
primaryAction, secondaryAction := u.bannerActionLabels(banner)
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), banner.Message)
lbl.Color = fg
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(banner.Detail) == "" {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), banner.Detail)
lbl.Color = fg
return lbl.Layout(gtx)
})
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if primaryAction == "" && secondaryAction == "" {
return layout.Dimensions{}
}
return layout.Inset{Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if primaryAction == "" {
return layout.Dimensions{}
}
click := &u.cancelLifecycleProgress
if primaryAction == "Retry" {
click = &u.retryLifecycleOpen
}
return tonedButton(gtx, u.theme, click, primaryAction)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if primaryAction == "" || secondaryAction == "" {
return layout.Dimensions{}
}
return layout.Spacer{Width: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if secondaryAction == "" {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.dismissBanner, secondaryAction)
}),
)
})
}),
)
})
})
}
func (u *ui) statusToast(gtx layout.Context) layout.Dimensions {
status := u.statusToastSurface()
if status.Kind == bannerNone {
return layout.Dimensions{}
}
max := gtx.Constraints.Max
gtx.Constraints.Min = max
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.End}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return statusToastCard(gtx, u.theme, status.Message)
}),
)
}),
)
})
}
func (u *ui) autofillStatusCard(gtx layout.Context) layout.Dimensions {
status := u.autofillStatusSurface()
if status.Kind == autofillStatusNone {
return layout.Dimensions{}
}
bg := color.NRGBA{R: 233, G: 241, B: 237, A: 255}
accent := accentColor
switch status.Kind {
case autofillStatusAmbiguous:
bg = color.NRGBA{R: 245, G: 239, B: 223, A: 255}
accent = color.NRGBA{R: 117, G: 88, B: 24, A: 255}
case autofillStatusBlocked:
bg = color.NRGBA{R: 247, G: 232, B: 228, A: 255}
accent = color.NRGBA{R: 125, G: 40, B: 30, A: 255}
case autofillStatusAwaitingApproval:
bg = color.NRGBA{R: 229, G: 236, B: 244, A: 255}
accent = color.NRGBA{R: 30, G: 76, B: 128, A: 255}
}
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(12), Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.Label(u.theme, unit.Sp(12), "Autofill")
label.Color = accent
label.Font.Weight = 600
return label.Layout(gtx)
})
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Label(u.theme, unit.Sp(14), status.Title)
label.Color = accent
label.Font.Weight = 600
return label.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.Label(u.theme, unit.Sp(12), status.Message)
label.Color = color.NRGBA{R: 52, G: 50, B: 46, A: 255}
return label.Layout(gtx)
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(status.Detail) == "" {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.Label(u.theme, unit.Sp(11), status.Detail)
label.Color = mutedColor
return label.Layout(gtx)
})
}),
)
}),
)
})
})
}
func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions {
history := u.visibleHistory()
u.ensureHistoryClickables()
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.toggleHistory.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon
if u.historyHidden {
icon = u.expandMoreIcon
}
if icon != nil {
return icon.Layout(gtx, accentColor)
}
lbl := material.Label(u.theme, unit.Sp(16), ">")
if !u.historyHidden {
lbl.Text = "v"
}
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "History")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
}
if u.historyHidden {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d saved version(s).", len(history)))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}))
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
if len(history) == 0 {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "No history for this entry yet.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}))
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
for i := range history {
index := i
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.historyRow(gtx, &u.historyClicks[index], index, history[index])
}))
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
if selected, ok := u.selectedHistoryEntry(); ok {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Selected Version")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Path", strings.Join(selected.Path, " / "))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Username", selected.Username)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "URL", selected.URL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body2(u.theme, selected.Notes)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
func (u *ui) attachmentSummaryPanel(gtx layout.Context) layout.Dimensions {
items := u.selectedAttachmentItems()
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "ATTACHMENTS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.attachmentActionSummary())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(items) == 0 {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(items)*2)
selectedName := strings.TrimSpace(u.attachmentName.Text())
for i, item := range items {
index := i
name := item.Name
selected := selectedName == name
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.attachmentClicks[index].Clicked(gtx) {
u.attachmentName.SetText(name)
}
return u.attachmentClicks[index].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Dp(unit.Dp(58))
}
bg := panelColor
if selected {
bg = selectedColor
}
paint.FillShape(gtx.Ops, bg, clip.Rect{Max: size}.Op())
if selected {
paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
}
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), name)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := fmt.Sprintf("%d B", item.Size)
if selected {
text += " · selected"
}
lbl := material.Label(u.theme, unit.Sp(11), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}),
)
})
}))
if i < len(items)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
})
}),
)
})
}
func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions {
for click.Clicked(gtx) {
_ = u.selectHistoryVersion(index)
}
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), fmt.Sprintf("Version %d", index))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), item.Username)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), item.URL)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body2(u.theme, item.Notes)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if index == u.selectedHistoryIndex {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op())
paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(row),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionRecycleBin {
return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries stay here until you restore them.")
}
u.syncCurrentPath()
displayPath := u.displayPath()
pathSource := displayPath
if u.state.Section == appstate.SectionTemplates {
pathSource = append([]string{}, u.currentPath...)
}
crumbs, indices := u.visibleBreadcrumbs(pathSource)
crumbBar := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(crumbs)*2)
for i, name := range crumbs {
index := i
label := name
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.breadcrumbs[index].Clicked(gtx) {
target := indices[index]
if target == 0 {
root := u.hiddenVaultRoot()
if root == "" {
u.setCurrentPath(nil)
} else {
u.setCurrentPath([]string{root})
}
} else {
nextPath := pathSource[:target]
root := u.hiddenVaultRoot()
if root != "" {
nextPath = append([]string{root}, nextPath...)
}
u.setCurrentPath(nextPath)
}
u.filter()
}
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index)))
btn.TextSize = unit.Sp(11)
if u.mode == "phone" {
btn.TextSize = unit.Sp(9)
btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6}
} else {
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
}
return btn.Layout(gtx)
}))
if i < len(crumbs)-1 {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "/")
lbl.Color = mutedColor
inset := unit.Dp(6)
if u.mode == "phone" {
inset = unit.Dp(4)
}
return layout.UniformInset(inset).Layout(gtx, lbl.Layout)
}))
}
}
return children
}()...)
}
return crumbBar(gtx)
}
func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) {
if u.state.Section == appstate.SectionTemplates {
return append([]string{"Templates"}, append([]string{}, u.currentPath...)...), func() []int {
indices := make([]int, 0, len(u.currentPath)+1)
indices = append(indices, 0)
for i := range u.currentPath {
indices = append(indices, i+1)
}
return indices
}()
}
if u.mode != "phone" || len(displayPath) <= 2 {
crumbs := append([]string{"/"}, append([]string{}, displayPath...)...)
indices := make([]int, 0, len(crumbs))
indices = append(indices, 0)
for i := range displayPath {
indices = append(indices, i+1)
}
return crumbs, indices
}
crumbs := []string{"/", "…", displayPath[len(displayPath)-1]}
indices := []int{0, len(displayPath) - 1, len(displayPath)}
return crumbs, indices
}
func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
groups := append([]string{}, u.childGroups()...)
if len(u.groupClicks) < len(groups) {
u.groupClicks = make([]widget.Clickable, len(groups))
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
if len(u.displayPath()) == 0 {
u.phoneGroupBrowserExpanded = true
}
children := make([]layout.FlexChild, 0, len(groups))
for i := range groups {
idx := i
name := groups[i]
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
})
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(groups) == 0 {
return layout.Dimensions{}
}
maxGroupListHeight := 200
if u.mode == "phone" {
maxGroupListHeight = 96
}
maxY := gtx.Dp(unit.Dp(maxGroupListHeight))
if gtx.Constraints.Max.Y > maxY {
gtx.Constraints.Max.Y = maxY
}
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
}
return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions {
idx := i
name := groups[i]
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
})
})
}),
)
})
}
func (u *ui) groupBarShowsExplicitNavigationButtons() bool {
return false
}
func (u *ui) topRightActionOrder() []string {
if u.isVaultLocked() {
return nil
}
return []string{"Sync", "Lock", "Menu"}
}
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
button := func(gtx layout.Context) layout.Dimensions {
icon := u.menuIcon
if icon == nil {
icon = u.settingsIcon
}
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
btn.Background = selectedColor
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
buttonDims := button(gtx)
if !u.mainMenuOpen {
return buttonDims
}
menuGTX := gtx
menuGTX.Constraints.Min = image.Point{}
menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.mainMenu)
menuCall := menuOps.Stop()
menuX := anchoredMenuX(buttonDims.Size.X, menuDims.Size.X)
stack := op.Offset(image.Pt(menuX, buttonDims.Size.Y)).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop()
return buttonDims
}
func (u *ui) syncMenuDropsBelowTrigger() bool {
return true
}
func (u *ui) syncMenuRightAlignsToTrigger() bool {
return true
}
func (u *ui) mainMenuDropsBelowTrigger() bool {
return true
}
func (u *ui) mainMenuRightAlignsToTrigger() bool {
return true
}
func anchoredMenuX(triggerWidth, menuWidth int) int {
return triggerWidth - menuWidth
}
func detailLine(th *material.Theme, label, value string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
valueSize := unit.Sp(16)
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
valueSize = unit.Sp(15)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, valueSize, value)
return lbl.Layout(gtx)
}),
)
}
}
func (u *ui) passwordLine(label, value string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
valueSize := unit.Sp(16)
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
valueSize = unit.Sp(15)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, valueSize, value)
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.togglePasswordInline, u.showPassword)
}),
)
}),
)
}
}
func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, showing bool) layout.Dimensions {
icon := u.eyeIcon
desc := "Show password"
if showing {
icon = u.eyeOffIcon
desc = "Hide password"
}
btn := material.IconButton(u.theme, click, icon, desc)
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
func (u *ui) detailPasswordValue() string {
item, ok := u.selectedEntry()
if !ok {
return ""
}
if u.showPassword {
return item.Password
}
return strings.Repeat("•", max(8, len(item.Password)))
}
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, w)
})
}
func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, w)
})
}
func emptyStatePanel(gtx layout.Context, th *material.Theme, state emptyState) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(15), state.Title)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), state.Body)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
func outlinedFieldStateWithPrefs(gtx layout.Context, prefs accessibilityPreferences, focused bool, w layout.Widget) layout.Dimensions {
appearance := fieldFocusAppearance(gtx.Metric, prefs, focused)
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = appearance.MinHeight
}
gtx.Constraints.Min = size
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{R: 255, G: 253, B: 249, A: 255}, clip.Rect{Max: size}.Op())
return layout.Dimensions{Size: size}
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return drawFocusOutline(gtx, appearance, size)
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
min := gtx.Constraints.Min
gtx.Constraints.Min = image.Point{}
dims := w(gtx)
if dims.Size.X < min.X {
dims.Size.X = min.X
}
if dims.Size.Y < min.Y {
dims.Size.Y = min.Y
}
if dims.Size.Y < appearance.MinHeight {
dims.Size.Y = appearance.MinHeight
}
return dims
}),
)
}
func (u *ui) outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions {
return outlinedFieldStateWithPrefs(gtx, u.accessibilityPrefs, focused, w)
}
func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
btn := material.Button(th, click, label)
btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(15)
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
btn.TextSize = unit.Sp(14)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10}
}
return btn.Layout(gtx)
}
func compactTonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
btn := material.Button(th, click, label)
btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(13)
btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 8, Right: 8}
return btn.Layout(gtx)
}
func syncPrimaryButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, compact bool) layout.Dimensions {
btn := material.Button(th, click, label)
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(14)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 12, Right: 12}
if compact {
btn.TextSize = unit.Sp(13)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10}
}
return btn.Layout(gtx)
}
func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions {
btn := material.Button(th, click, label)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(14)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 11, Right: 11}
if active {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
return btn.Layout(gtx)
}
func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
}
func syncDialogSummaryText(purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) string {
if purpose == syncDialogPurposeRemoteSetup {
return "Push this local vault to a WebDAV target and save that target for future sync."
}
sourceLabel := "another local vault file"
if source == syncSourceRemote {
sourceLabel = "another WebDAV-backed vault"
}
action := "Pull changes from"
if direction == syncDirectionPush {
action = "Push the current vault into"
}
return action + " " + sourceLabel + "."
}
func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), "SYNC PLAN")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction))
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
})
}
func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(title))
lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255}
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), "DELETED ENTRY")
lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255}
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(16), title)
return lbl.Layout(gtx)
}),
)
})
})
}
func statusToastCard(gtx layout.Context, th *material.Theme, message string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 27, G: 58, B: 47, A: 235}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), message)
lbl.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
return lbl.Layout(gtx)
})
})
}
func sectionTabButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions {
btn := material.Button(th, click, label)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(11)
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
btn.TextSize = unit.Sp(9)
btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 7, Right: 7}
}
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(220)) {
btn.TextSize = unit.Sp(9)
btn.Inset = layout.Inset{Top: 4, Bottom: 4, Left: 6, Right: 6}
}
if active {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = selectedColor
btn.Color = accentColor
}
return btn.Layout(gtx)
}
func fill(c color.NRGBA) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, c, clip.Rect{Max: gtx.Constraints.Min}.Op())
return layout.Dimensions{Size: gtx.Constraints.Min}
}
}
func main() {
mode := flag.String("mode", "", "window mode: desktop or phone")
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
width := unit.Dp(1180)
height := unit.Dp(760)
if strings.EqualFold(resolvedMode, "phone") {
// Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview.
width = unit.Dp(412)
height = unit.Dp(915)
}
go func() {
w := new(app.Window)
options := []app.Option{app.Title(productName)}
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
options = append(options, app.Size(width, height))
}
w.Option(options...)
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
panic(err)
}
if !strings.EqualFold(runtime.GOOS, "android") {
os.Exit(0)
}
}()
app.Main()
}
func defaultGRPCAddr(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
return "127.0.0.1:47777"
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
var ops op.Ops
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
ui.fileExplorer = explorer.NewExplorer(w)
ui.invalidate = w.Invalidate
ui.clipboardWriter = newPlatformClipboardWriter(runtime.GOOS, w.Invalidate)
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
if err != nil {
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
} else if host != nil {
ui.apiHost = host
ui.auditLog = host.Server().AuditLog()
ui.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
defer func() { _ = host.Stop() }()
}
for {
e := w.Event()
ui.fileExplorer.ListenEvents(e)
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
ui.processBackgroundActions()
ui.layout(gtx)
processClipboardWrites(gtx, ui.clipboardWriter)
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 parsePickedFilePath(output)
}
func parsePickedFilePath(output []byte) (string, error) {
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
return line, nil
}
}
return "", fmt.Errorf("file picker did not return a path")
}