7668 lines
255 KiB
Go
7668 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 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
|
|
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},
|
|
},
|
|
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.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.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 (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 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.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
|
}
|
|
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.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(20), 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)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !u.mainMenuOpen {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, u.mainMenu)
|
|
}),
|
|
)
|
|
}
|
|
if u.shouldShowDesktopWorkingHeader() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return u.brandMark(gtx, 196, 56)
|
|
}),
|
|
layout.Rigid(u.headerActions),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|
if u.shouldShowLifecycleSetup() {
|
|
return layout.Dimensions{}
|
|
}
|
|
if u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
if u.shouldShowDesktopWorkingHeader() {
|
|
return layout.Dimensions{}
|
|
}
|
|
row := func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(u.syncButtonGroup),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
icon := u.menuIcon
|
|
if icon == nil {
|
|
icon = u.settingsIcon
|
|
}
|
|
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
|
|
btn.Background = selectedColor
|
|
btn.Color = accentColor
|
|
btn.Size = unit.Dp(18)
|
|
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
return btn.Layout(gtx)
|
|
}),
|
|
)
|
|
}
|
|
if u.mode == "phone" {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Dimensions{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)
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.syncMenuToggle(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !u.syncMenuOpen {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.syncMenu)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
|
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
|
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
|
|
btn.Color = accentColor
|
|
btn.Size = unit.Dp(18)
|
|
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
if u.mode == "phone" {
|
|
btn.Size = unit.Dp(16)
|
|
btn.Inset = layout.UniformInset(unit.Dp(7))
|
|
}
|
|
return btn.Layout(gtx)
|
|
}
|
|
|
|
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
|
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.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) 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))
|
|
if u.state.Section != appstate.SectionAbout {
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
|
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
|
|
editor.Color = u.theme.Palette.Fg
|
|
editor.HintColor = mutedColor
|
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
|
})
|
|
})
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
})
|
|
}
|
|
if !u.isVaultLocked() {
|
|
rows = append(rows, u.navigationHeader)
|
|
if u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionAbout {
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
})
|
|
}
|
|
}
|
|
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
|
|
rows = append(rows, u.pathBar)
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
})
|
|
}
|
|
if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries {
|
|
rows = append(rows, u.groupBar)
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
})
|
|
rows = append(rows, u.groupControlsSection)
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
})
|
|
}
|
|
if !u.isVaultLocked() {
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
switch u.state.Section {
|
|
case appstate.SectionEntries:
|
|
btn := material.Button(u.theme, &u.addEntry, "+ Add Entry")
|
|
return btn.Layout(gtx)
|
|
case appstate.SectionAPITokens:
|
|
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
|
case appstate.SectionAbout:
|
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
|
default:
|
|
return layout.Dimensions{}
|
|
}
|
|
})
|
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
})
|
|
}
|
|
switch {
|
|
case u.state.Section == appstate.SectionAPITokens:
|
|
rows = append(rows, u.apiTokenListPanel)
|
|
case u.state.Section == appstate.SectionAPIAudit:
|
|
rows = append(rows, u.apiAuditListPanel)
|
|
case 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 {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.navigationHeader(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.state.Section == appstate.SectionAbout {
|
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
|
}
|
|
return layout.Dimensions{}
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.state.Section == appstate.SectionAbout {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.pathBar(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.groupBar(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.groupControlsSection(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.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)
|
|
})
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(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{}
|
|
}
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
if u.state.Section == appstate.SectionAPITokens {
|
|
return u.apiTokenListPanel(gtx)
|
|
}
|
|
if u.state.Section == appstate.SectionAPIAudit {
|
|
return u.apiAuditListPanel(gtx)
|
|
}
|
|
if u.state.Section == appstate.SectionAbout {
|
|
return layout.Dimensions{}
|
|
}
|
|
if len(u.visible) == 0 {
|
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
|
}
|
|
return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions {
|
|
item := u.visible[i]
|
|
click := &u.entryClicks[i]
|
|
return u.entryRow(gtx, click, i, item)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions {
|
|
if u.mode == "phone" {
|
|
if u.state.Section != appstate.SectionEntries && 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)
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) 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.sectionBar(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.state.Section != appstate.SectionEntries {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.groupControlsDisclosure(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
|
|
tabs := []struct {
|
|
click *widget.Clickable
|
|
label string
|
|
compact string
|
|
active bool
|
|
}{
|
|
{click: &u.showEntries, label: "Entries", compact: "Entries", active: u.state.Section == appstate.SectionEntries},
|
|
{click: &u.showRecycle, label: "Recycle Bin", compact: "Recycle", active: u.state.Section == appstate.SectionRecycleBin},
|
|
{click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens},
|
|
{click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit},
|
|
}
|
|
if u.mode == "phone" {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].compact, tabs[0].active)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].compact, tabs[1].active)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].compact, tabs[2].active)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].compact, tabs[3].active)
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].label, tabs[0].active)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].label, tabs[1].active)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].label, tabs[2].active)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].label, tabs[3].active)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions {
|
|
for click.Clicked(gtx) {
|
|
_ = u.state.ToggleVisibleIndex(idx)
|
|
u.loadSelectedEntryIntoEditor()
|
|
}
|
|
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics()
|
|
selected := item.ID == u.state.SelectedEntryID
|
|
focused := u.isFocused(listFocusID(idx))
|
|
rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin)
|
|
row := func(gtx layout.Context) layout.Dimensions {
|
|
return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin
|
|
hasUsername := strings.TrimSpace(item.Username) != ""
|
|
hasURL := strings.TrimSpace(item.URL) != ""
|
|
pathText := strings.Join(u.displayEntryPath(item.Path), " / ")
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, titleSize, item.Title)
|
|
lbl.Color = rowColors.Title
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !hasUsername {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !hasUsername {
|
|
return layout.Dimensions{}
|
|
}
|
|
lbl := material.Label(u.theme, metaSize, item.Username)
|
|
lbl.Color = rowColors.Meta
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !hasURL {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !hasURL {
|
|
return layout.Dimensions{}
|
|
}
|
|
lbl := material.Label(u.theme, urlSize, item.URL)
|
|
lbl.Color = rowColors.Secondary
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !showPath {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !showPath {
|
|
return layout.Dimensions{}
|
|
}
|
|
lbl := material.Label(u.theme, pathSize, pathText)
|
|
lbl.Color = rowColors.Secondary
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: dividerGap}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
w := gtx.Constraints.Max.X
|
|
if w < 1 {
|
|
w = 1
|
|
}
|
|
paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op())
|
|
return layout.Dimensions{Size: image.Pt(w, 1)}
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
if selected || focused {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
size := gtx.Constraints.Min
|
|
if size.X == 0 {
|
|
size.X = gtx.Constraints.Max.X
|
|
}
|
|
if size.Y == 0 {
|
|
size.Y = gtx.Constraints.Max.Y
|
|
}
|
|
paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op())
|
|
paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op())
|
|
return layout.Dimensions{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
|
return row(gtx)
|
|
}),
|
|
)
|
|
}
|
|
bg := panelColor
|
|
if u.state.Section == appstate.SectionRecycleBin {
|
|
bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255}
|
|
}
|
|
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
|
|
return row(gtx)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions {
|
|
if u.mode != "phone" {
|
|
return layout.Dimensions{}
|
|
}
|
|
for {
|
|
e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical)
|
|
if !ok {
|
|
break
|
|
}
|
|
switch e.Kind {
|
|
case pointer.Press:
|
|
u.splitBase = u.phoneSplit.Value
|
|
u.splitStartY = e.Position.Y
|
|
case pointer.Drag:
|
|
if u.phoneSpan > 0 {
|
|
next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan)
|
|
if next < 0.28 {
|
|
next = 0.28
|
|
}
|
|
if next > 0.72 {
|
|
next = 0.72
|
|
}
|
|
u.phoneSplit.Value = next
|
|
}
|
|
}
|
|
}
|
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
|
|
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(24))
|
|
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
|
u.splitDrag.Add(gtx.Ops)
|
|
pointer.CursorRowResize.Add(gtx.Ops)
|
|
handleW := gtx.Dp(unit.Dp(108))
|
|
handleH := gtx.Dp(unit.Dp(6))
|
|
x := (gtx.Constraints.Min.X - handleW) / 2
|
|
y := (gtx.Constraints.Min.Y - handleH) / 2
|
|
paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+2), Max: image.Pt(gtx.Constraints.Min.X, y+3)}.Op())
|
|
paint.FillShape(gtx.Ops, accentColor, clip.RRect{
|
|
Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)},
|
|
NE: 2, NW: 2, SE: 2, SW: 2,
|
|
}.Op(gtx.Ops))
|
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
|
})
|
|
}
|
|
|
|
func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
|
panel := card
|
|
if u.mode == "phone" {
|
|
panel = compactCard
|
|
}
|
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
if u.shouldShowDesktopWorkingHeader() {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Dimensions{}
|
|
}),
|
|
layout.Rigid(u.syncButtonGroup),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
icon := u.menuIcon
|
|
if icon == nil {
|
|
icon = u.settingsIcon
|
|
}
|
|
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
|
|
btn.Background = selectedColor
|
|
btn.Color = accentColor
|
|
btn.Size = unit.Dp(18)
|
|
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
return btn.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !u.mainMenuOpen {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.mainMenu)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return u.detailPanelContent(gtx)
|
|
}),
|
|
)
|
|
}
|
|
return u.detailPanelContent(gtx)
|
|
})
|
|
}
|
|
|
|
func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
|
panel := layout.Flex{Axis: layout.Vertical}
|
|
_ = panel
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
|
|
if u.isVaultLocked() {
|
|
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
|
|
}()...)
|
|
}
|
|
if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() {
|
|
return crumbBar(gtx)
|
|
}
|
|
children := []layout.FlexChild{
|
|
layout.Rigid(crumbBar),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
}
|
|
if u.shouldShowDirectRemoteSyncShortcut() {
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel())
|
|
}))
|
|
}
|
|
if u.shouldShowRemoteSyncSetupShortcut() {
|
|
if u.shouldShowDirectRemoteSyncShortcut() {
|
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
}
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel())
|
|
}))
|
|
}
|
|
if u.shouldShowRemoteSyncSettingsShortcut() {
|
|
if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() {
|
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
}
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel())
|
|
}))
|
|
}
|
|
if u.shouldShowRemoveRemoteSyncShortcut() {
|
|
if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() || u.shouldShowRemoteSyncSettingsShortcut() {
|
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
}
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel())
|
|
}))
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
}
|
|
|
|
func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) {
|
|
if u.state.Section == appstate.SectionTemplates {
|
|
return append([]string{"Templates"}, append([]string{}, u.currentPath...)...), func() []int {
|
|
indices := make([]int, 0, len(u.currentPath)+1)
|
|
indices = append(indices, 0)
|
|
for i := range u.currentPath {
|
|
indices = append(indices, i+1)
|
|
}
|
|
return indices
|
|
}()
|
|
}
|
|
if u.mode != "phone" || len(displayPath) <= 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))
|
|
}
|
|
displayPath := u.displayPath()
|
|
atRoot := len(displayPath) == 0
|
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
if u.mode == "phone" {
|
|
if atRoot {
|
|
u.phoneGroupBrowserExpanded = true
|
|
}
|
|
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 atRoot {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
for u.goToRootGroup.Clicked(gtx) {
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" {
|
|
u.setCurrentPath(nil)
|
|
} else {
|
|
u.setCurrentPath([]string{root})
|
|
}
|
|
u.filter()
|
|
}
|
|
return tonedButton(gtx, u.theme, &u.goToRootGroup, "Back to Root")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
for u.goToParentGroup.Clicked(gtx) {
|
|
u.setCurrentPath(u.currentPath[:len(u.currentPath)-1])
|
|
u.filter()
|
|
}
|
|
return tonedButton(gtx, u.theme, &u.goToParentGroup, "Up One Group")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if len(groups) == 0 {
|
|
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 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")
|
|
}
|