Files
keepassgo/main.go
T
2026-03-29 16:54:20 -07:00

2196 lines
66 KiB
Go

package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"image"
"image/color"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"gioui.org/app"
"gioui.org/gesture"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/appstate"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type entry = vault.Entry
const (
productName = "KeePassGO"
)
const (
maxAttachmentBytes = 10 << 20
statusBannerDuration = 4 * time.Second
)
type bannerKind string
const (
bannerNone bannerKind = ""
bannerLoading bannerKind = "loading"
bannerError bannerKind = "error"
bannerStatus bannerKind = "status"
)
type uiBanner struct {
Kind bannerKind
Message string
}
type uiSurface struct {
Title string
Message string
Locked bool
}
type sessionStatus interface {
HasVault() bool
IsLocked() bool
IsRemote() bool
}
type attachmentItem struct {
Name string
Size int
}
type statePaths struct {
DefaultSaveAsPath string
RecentVaultsPath string
}
type recentVaultRecord struct {
Path string `json:"path"`
LastGroup []string `json:"lastGroup,omitempty"`
}
type ui struct {
mode string
theme *material.Theme
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
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
passwordProfile widget.Editor
attachmentName widget.Editor
attachmentPath widget.Editor
exportAttachmentPath widget.Editor
list widget.List
detailList widget.List
copyUser widget.Clickable
copyPass widget.Clickable
copyURL widget.Clickable
lockVault widget.Clickable
unlockVault widget.Clickable
createVault widget.Clickable
openVault widget.Clickable
saveVault widget.Clickable
saveAsVault widget.Clickable
openRemote widget.Clickable
changeMasterKey widget.Clickable
synchronizeVault widget.Clickable
editEntry widget.Clickable
cancelEdit widget.Clickable
pickVaultPath widget.Clickable
pickKeyFile widget.Clickable
addEntry widget.Clickable
saveEntry widget.Clickable
duplicateEntry widget.Clickable
deleteEntry widget.Clickable
restoreEntry widget.Clickable
saveTemplate widget.Clickable
deleteTemplate widget.Clickable
instantiateTemplate widget.Clickable
addAttachment widget.Clickable
replaceAttachment widget.Clickable
removeAttachment widget.Clickable
exportAttachment widget.Clickable
restoreHistory widget.Clickable
generatePassword widget.Clickable
createGroup widget.Clickable
renameGroup widget.Clickable
deleteGroup widget.Clickable
confirmDeleteGroup widget.Clickable
cancelDeleteGroup widget.Clickable
addCustomField widget.Clickable
toggleGroupControls widget.Clickable
togglePasswordInline widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable
entryClicks []widget.Clickable
historyClicks []widget.Clickable
attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable
groupClicks []widget.Clickable
recentVaultClicks []widget.Clickable
removeCustomFields []widget.Clickable
state appstate.State
visible []entry
currentPath []string
syncedPath []string
selectedHistoryIndex int
showPassword bool
togglePassword widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
splitStartY float32
phoneSpan int
eyeIcon *widget.Icon
eyeOffIcon *widget.Icon
copyIcon *widget.Icon
expandMoreIcon *widget.Icon
expandLessIcon *widget.Icon
clipboardWriter clipboard.Writer
loadingMessage string
lifecycleMode string
keyboardFocus focusID
defaultSaveAsPath string
recentVaultsPath string
editingEntry bool
groupControlsHidden bool
recentVaults []string
recentVaultGroups map[string][]string
deleteGroupPath []string
statusExpiresAt time.Time
now func() time.Time
}
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 newUI(mode string, paths statePaths) *ui {
return newUIWithSession(mode, &session.Manager{}, paths)
}
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,
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},
masterPassword: widget.Editor{SingleLine: true, Submit: false},
keyFilePath: 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},
passwordProfile: widget.Editor{SingleLine: true, Submit: false},
attachmentName: widget.Editor{SingleLine: true, Submit: false},
attachmentPath: widget.Editor{SingleLine: true, Submit: false},
exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false},
list: widget.List{
List: layout.List{Axis: layout.Vertical},
},
detailList: widget.List{
List: layout.List{Axis: layout.Vertical},
},
state: appstate.State{},
selectedHistoryIndex: -1,
lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath,
recentVaultGroups: map[string][]string{},
now: time.Now,
}
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.passwordProfile.SetText("strong")
u.keyboardFocus = focusSearch
u.setCustomFieldRows(nil)
u.loadRecentVaults()
u.filter()
return u
}
func (u *ui) filter() {
u.state.SetSearchQuery(u.search.Text())
visible, err := u.state.VisibleEntries()
if err != nil {
u.visible = nil
return
}
u.visible = visible
if len(u.entryClicks) < len(u.visible) {
u.entryClicks = make([]widget.Clickable, len(u.visible))
}
}
func defaultStatePaths(stateDir string) statePaths {
baseDir := strings.TrimSpace(stateDir)
if baseDir == "" {
baseDir = strings.TrimSpace(os.Getenv("KEEPASSGO_STATE_DIR"))
}
if baseDir == "" {
configDir, err := os.UserConfigDir()
if err != nil || strings.TrimSpace(configDir) == "" {
configDir = os.TempDir()
}
baseDir = filepath.Join(configDir, "keepassgo")
}
return statePaths{
DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
}
}
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 (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.state.ShowSection(appstate.SectionEntries)
u.filter()
}
func (u *ui) showTemplatesSection() {
u.state.ShowSection(appstate.SectionTemplates)
u.filter()
}
func (u *ui) showRecycleBinSection() {
u.state.ShowSection(appstate.SectionRecycleBin)
u.filter()
}
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.CreateVault(key); err != nil {
return err
}
if u.lifecycleMode == "local" {
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
return err
}
u.vaultPath.SetText(u.saveAsTargetPath())
u.noteRecentVault(u.saveAsTargetPath())
}
u.currentPath = append([]string(nil), u.state.CurrentPath...)
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.currentPath = append([]string(nil), u.state.CurrentPath...)
u.restoreRecentVaultGroup(path)
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) saveAction() error {
if err := u.state.Save(); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveAsAction() error {
path := u.saveAsTargetPath()
if err := u.state.SaveAs(path); err != nil {
return err
}
u.vaultPath.SetText(path)
u.noteRecentVault(path)
u.filter()
return nil
}
func (u *ui) openRemoteAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err
}
u.enterHiddenVaultRoot()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) lockAction() error {
u.clearMasterPassword()
if err := u.state.Lock(); err != nil {
return err
}
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.showPassword = false
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.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("")
}
func (u *ui) synchronizeAction() error {
if err := u.state.Synchronize(); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveAsTargetPath() string {
path := strings.TrimSpace(u.saveAsPath.Text())
if path != "" {
return path
}
return u.defaultSaveAsPath
}
func (u *ui) noteRecentVault(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
if u.recentVaultGroups == nil {
u.recentVaultGroups = map[string][]string{}
}
if len(u.currentPath) > 0 {
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
} else if _, ok := u.recentVaultGroups[path]; !ok {
u.recentVaultGroups[path] = nil
}
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{}
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{}
}
u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...)
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) 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]...),
})
}
content, err := json.MarshalIndent(records, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.recentVaultsPath, content, 0o600)
}
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) displayPath() []string {
path := append([]string(nil), u.currentPath...)
root := u.hiddenVaultRoot()
if root == "" || len(path) == 0 || path[0] != root {
return path
}
return append([]string(nil), path[1:]...)
}
func (u *ui) displayEntryPath(path []string) []string {
root := u.hiddenVaultRoot()
if root == "" || len(path) == 0 || path[0] != root {
return append([]string(nil), path...)
}
return append([]string(nil), path[1:]...)
}
func pathHasPrefix(path, prefix []string) bool {
if len(prefix) > len(path) {
return false
}
return slices.Equal(path[:len(prefix)], prefix)
}
func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups {
if slices.Equal(group, path) {
return true
}
}
return false
}
func (u *ui) currentGroupDeletionState() (bool, string) {
u.syncCurrentPath()
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
return false, ""
}
model, err := u.state.Session.Current()
if err != nil {
return false, ""
}
path := append([]string(nil), u.currentPath...)
if len(model.ChildGroups(path)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group."
}
for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
return false, "This group contains entries. Move or delete them before removing the group."
}
}
for _, item := range model.Templates {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
return false, "This group contains templates. Move or delete them before removing the group."
}
}
return true, "Deleting this empty group will not remove any entries."
}
func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
}
func (u *ui) clearDeleteGroupConfirmation() {
u.deleteGroupPath = nil
}
func (u *ui) armDeleteCurrentGroupAction() {
if deletable, _ := u.currentGroupDeletionState(); !deletable {
return
}
u.syncCurrentPath()
u.deleteGroupPath = append([]string(nil), u.currentPath...)
u.state.ErrorMessage = ""
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))
u.statusExpiresAt = u.now().Add(statusBannerDuration)
}
func (u *ui) runAction(label string, action func() error) {
u.loadingMessage = actionLoadingLabel(label)
if err := action(); err != nil {
u.loadingMessage = ""
u.state.ErrorMessage = u.describeActionError(label, err)
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.loadingMessage = ""
u.state.ErrorMessage = ""
if suppressStatusMessage(label) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.state.StatusMessage = label + " complete"
u.statusExpiresAt = u.now().Add(statusBannerDuration)
}
func suppressStatusMessage(label string) bool {
switch strings.TrimSpace(label) {
case "open vault", "open remote vault":
return true
default:
return false
}
}
func actionLoadingLabel(label string) string {
label = strings.TrimSpace(label)
if label == "" {
return "Working..."
}
runes := []rune(label)
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
return string(runes) + "..."
}
func (u *ui) describeActionError(label string, err error) string {
if err == nil {
return ""
}
if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) {
return "Save conflict: the remote vault changed. Reopen it and retry the save."
}
if label == "open remote vault" {
return fmt.Sprintf("%s failed: %v", label, err)
}
return err.Error()
}
func (u *ui) bannerSurface() uiBanner {
switch {
case strings.TrimSpace(u.loadingMessage) != "":
return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)}
case strings.TrimSpace(u.state.ErrorMessage) != "":
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
case strings.TrimSpace(u.state.StatusMessage) != "":
if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return uiBanner{}
}
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)}
default:
return uiBanner{}
}
}
func (u *ui) sessionSurface() uiSurface {
if u.state.Session == nil {
return uiSurface{}
}
if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) {
return uiSurface{
Title: "Vault locked",
Message: "Enter a master password, choose a key file, or provide both to unlock the vault.",
Locked: true,
}
}
return uiSurface{}
}
func (u *ui) hasOpenVault() bool {
status, ok := u.state.Session.(sessionStatus)
if ok {
return status.HasVault()
}
_, err := u.state.Session.Current()
return err == nil
}
func (u *ui) isVaultLocked() bool {
status, ok := u.state.Session.(sessionStatus)
if ok {
return status.IsLocked()
}
_, err := u.state.Session.Current()
return errors.Is(err, session.ErrLocked)
}
func (u *ui) shouldShowLifecycleSetup() bool {
return !u.hasOpenVault()
}
func (u *ui) shouldUseLockedSinglePane() bool {
return u.isVaultLocked() && !u.shouldShowLifecycleSetup()
}
func (u *ui) chooseExistingFileAction(target *widget.Editor) error {
path, err := pickExistingFile()
if err != nil {
return err
}
target.SetText(path)
return nil
}
func (u *ui) listEmptyMessage() string {
if surface := u.sessionSurface(); surface.Locked {
return "Unlock the vault to browse entries and groups."
}
query := strings.TrimSpace(u.search.Text())
if query != "" {
switch u.state.Section {
case appstate.SectionTemplates:
return fmt.Sprintf("No templates match %q. Clear or refine the search.", query)
case appstate.SectionRecycleBin:
return fmt.Sprintf("No recycle-bin entries match %q. Clear or refine the search.", query)
default:
return fmt.Sprintf("No entries match %q. Clear or refine the search.", query)
}
}
switch u.state.Section {
case appstate.SectionTemplates:
return "Templates are not available in this build."
case appstate.SectionRecycleBin:
return "Recycle Bin is empty."
default:
return "Create or open a vault, then add an entry to get started."
}
}
func (u *ui) detailPlaceholderMessage() string {
if surface := u.sessionSurface(); surface.Locked {
return "Unlock the vault to inspect entries, attachments, and history."
}
if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" ||
strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" ||
strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" {
return "Complete the form to create a new item or update the current selection."
}
switch u.state.Section {
case appstate.SectionTemplates:
return "Select a template or start a reusable entry."
case appstate.SectionRecycleBin:
return "Select a recycle-bin entry to review or restore it."
default:
return "Select an entry or start a new one."
}
}
func (u *ui) ensureNavClickables() {
u.syncCurrentPath()
if len(u.breadcrumbs) < len(u.currentPath)+1 {
u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1)
}
}
func (u *ui) setCurrentPath(path []string) {
u.currentPath = append([]string(nil), path...)
u.state.NavigateToPath(path)
u.syncedPath = append([]string(nil), path...)
u.noteCurrentVaultPath()
u.clearDeleteGroupConfirmation()
}
func (u *ui) syncCurrentPath() {
switch {
case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
u.currentPath = append([]string(nil), u.state.CurrentPath...)
case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath):
u.state.CurrentPath = append([]string(nil), u.currentPath...)
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.syncedPath = append([]string(nil), u.currentPath...)
u.noteCurrentVaultPath()
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
u.clearDeleteGroupConfirmation()
}
}
func (u *ui) noteCurrentVaultPath() {
status, ok := u.state.Session.(sessionStatus)
if !ok || status.IsRemote() || status.IsLocked() {
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 {
u.processShortcuts(gtx)
for u.createVault.Clicked(gtx) {
u.runAction("create vault", u.createVaultAction)
}
for u.openVault.Clicked(gtx) {
u.runAction("open vault", u.openVaultAction)
}
for u.saveVault.Clicked(gtx) {
u.runAction("save vault", u.saveAction)
}
for u.saveAsVault.Clicked(gtx) {
u.runAction("save-as vault", u.saveAsAction)
}
for u.openRemote.Clicked(gtx) {
u.runAction("open remote vault", u.openRemoteAction)
}
for u.changeMasterKey.Clicked(gtx) {
u.runAction("change master key", u.changeMasterKeyAction)
}
for u.synchronizeVault.Clicked(gtx) {
u.runAction("synchronize vault", u.synchronizeAction)
}
for u.unlockVault.Clicked(gtx) {
u.runAction("unlock vault", u.unlockAction)
}
for u.showEntries.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showEntriesSection()
}
for u.showTemplates.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showTemplatesSection()
}
for u.showRecycle.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showRecycleBinSection()
}
for u.showLocalLifecycle.Clicked(gtx) {
u.lifecycleMode = "local"
}
for u.showRemoteLifecycle.Clicked(gtx) {
u.lifecycleMode = "remote"
}
for u.lockVault.Clicked(gtx) {
u.runAction("lock vault", u.lockAction)
}
for u.editEntry.Clicked(gtx) {
u.editingEntry = true
u.loadSelectedEntryIntoEditor()
}
for u.cancelEdit.Clicked(gtx) {
u.editingEntry = false
u.loadSelectedEntryIntoEditor()
}
for u.pickVaultPath.Clicked(gtx) {
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
}
for u.pickKeyFile.Clicked(gtx) {
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
}
for i := range u.recentVaultClicks {
for u.recentVaultClicks[i].Clicked(gtx) {
if i < len(u.recentVaults) {
u.vaultPath.SetText(u.recentVaults[i])
}
}
}
for u.addEntry.Clicked(gtx) {
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.editingEntry = true
}
for u.saveEntry.Clicked(gtx) {
u.runAction("save entry", u.saveEntryAction)
}
for u.duplicateEntry.Clicked(gtx) {
u.runAction("duplicate entry", u.duplicateSelectedEntryAction)
}
for u.deleteEntry.Clicked(gtx) {
u.runAction("delete entry", u.deleteSelectedEntryAction)
}
for u.restoreEntry.Clicked(gtx) {
u.runAction("restore entry", u.restoreSelectedRecycleEntryAction)
}
for u.saveTemplate.Clicked(gtx) {
u.runAction("save template", u.saveTemplateAction)
}
for u.deleteTemplate.Clicked(gtx) {
u.runAction("delete template", u.deleteSelectedTemplateAction)
}
for u.instantiateTemplate.Clicked(gtx) {
u.runAction("instantiate template", u.instantiateSelectedTemplateAction)
}
for u.addAttachment.Clicked(gtx) {
u.runAction("add attachment", u.addAttachmentAction)
}
for u.replaceAttachment.Clicked(gtx) {
u.runAction("replace attachment", u.replaceAttachmentAction)
}
for u.removeAttachment.Clicked(gtx) {
u.runAction("remove attachment", u.removeAttachmentAction)
}
for u.exportAttachment.Clicked(gtx) {
u.runAction("export attachment", u.exportAttachmentAction)
}
for u.copyUser.Clicked(gtx) {
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
}
for u.copyPass.Clicked(gtx) {
u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) })
}
for u.copyURL.Clicked(gtx) {
u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) })
}
for u.generatePassword.Clicked(gtx) {
u.runAction("generate password", u.generatePasswordAction)
}
for u.restoreHistory.Clicked(gtx) {
u.runAction("restore history", u.restoreSelectedHistoryAction)
}
for u.createGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("create group", u.createGroupAction)
}
for u.toggleGroupControls.Clicked(gtx) {
u.groupControlsHidden = !u.groupControlsHidden
}
for u.renameGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("rename group", u.renameGroupAction)
}
for u.deleteGroup.Clicked(gtx) {
u.armDeleteCurrentGroupAction()
}
for u.confirmDeleteGroup.Clicked(gtx) {
u.runAction("delete group", u.deleteCurrentGroupAction)
u.clearDeleteGroupConfirmation()
}
for u.cancelDeleteGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
}
for u.togglePassword.Clicked(gtx) {
u.showPassword = !u.showPassword
}
for u.togglePasswordInline.Clicked(gtx) {
u.showPassword = !u.showPassword
}
if _, changed := u.search.Update(gtx); changed {
u.filter()
}
inset := layout.UniformInset(unit.Dp(16))
return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(u.header),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.bannerSurface().Kind == bannerNone {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(u.banner),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
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),
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),
)
}),
)
})
})
}
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
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 layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), productName)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(u.headerActions),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.lifecycleControls),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
)
}),
)
})
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(24), productName)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(u.headerActions),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(u.lifecycleControls),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
)
}),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
if u.isVaultLocked() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize")
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}),
)
}
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
panel := card
spacing := unit.Dp(12)
if u.mode == "phone" {
panel = compactCard
spacing = unit.Dp(8)
}
u.ensureNavClickables()
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
return layout.Dimensions{}
}
return u.sectionBar(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
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() {
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() {
return layout.Dimensions{}
}
return u.groupControlsSection(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
}
return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, "Search vault")
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
})
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
label := "Add Entry"
if u.mode == "phone" {
label = "+ " + label
}
btn := material.Button(u.theme, &u.addEntry, label)
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(u.visible) == 0 {
lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions {
item := u.visible[i]
click := &u.entryClicks[i]
return u.entryRow(gtx, click, i, item)
})
}),
)
})
}
func (u *ui) sectionBar(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.showEntries, "Entries")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
}),
)
}
func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions {
for click.Clicked(gtx) {
_ = u.state.ToggleVisibleIndex(idx)
u.loadSelectedEntryIntoEditor()
}
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
inset := unit.Dp(12)
titleSize := unit.Sp(18)
metaSize := unit.Sp(14)
urlSize := unit.Sp(13)
if u.mode == "phone" {
inset = unit.Dp(10)
titleSize = unit.Sp(16)
metaSize = unit.Sp(13)
urlSize = unit.Sp(12)
}
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, titleSize, item.Title)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, metaSize, item.Username)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, urlSize, item.URL)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(u.search.Text()) == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
w := gtx.Constraints.Max.X
if w < 1 {
w = 1
}
paint.FillShape(gtx.Ops, color.NRGBA{R: 232, G: 227, B: 219, A: 255}, clip.Rect{Max: image.Pt(w, 1)}.Op())
return layout.Dimensions{Size: image.Pt(w, 1)}
}),
)
})
}
if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
fillColor := selectedColor
edgeColor := selectedEdge
if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID {
fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255}
edgeColor = accentColor
}
paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op())
paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return row(gtx)
}),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
return row(gtx)
})
})
}
func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions {
if u.mode != "phone" {
return layout.Dimensions{}
}
for {
e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical)
if !ok {
break
}
switch e.Kind {
case pointer.Press:
u.splitBase = u.phoneSplit.Value
u.splitStartY = e.Position.Y
case pointer.Drag:
if u.phoneSpan > 0 {
next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan)
if next < 0.28 {
next = 0.28
}
if next > 0.72 {
next = 0.72
}
u.phoneSplit.Value = next
}
}
}
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(18))
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(18))
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
u.splitDrag.Add(gtx.Ops)
pointer.CursorRowResize.Add(gtx.Ops)
handleW := gtx.Dp(unit.Dp(84))
handleH := gtx.Dp(unit.Dp(4))
x := (gtx.Constraints.Min.X - handleW) / 2
y := (gtx.Constraints.Min.Y - handleH) / 2
paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+1), Max: image.Pt(gtx.Constraints.Min.X, y+2)}.Op())
paint.FillShape(gtx.Ops, accentColor, clip.RRect{
Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)},
NE: 2, NW: 2, SE: 2, SW: 2,
}.Op(gtx.Ops))
return layout.Dimensions{Size: gtx.Constraints.Min}
})
}
func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
panel := card
if u.mode == "phone" {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
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),
)
}
item, ok := u.selectedEntry()
if !ok && !u.editingEntry {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
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 material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
password := u.detailPasswordValue()
titleSize := unit.Sp(26)
titlePad := unit.Dp(10)
sectionGap := unit.Dp(8)
if u.mode == "phone" {
titleSize = unit.Sp(18)
titlePad = unit.Dp(6)
sectionGap = unit.Dp(6)
}
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, titleSize, item.Title)
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: titlePad}.Layout,
detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / ")),
layout.Spacer{Height: sectionGap}.Layout,
detailLine(u.theme, "Username", item.Username),
layout.Spacer{Height: sectionGap}.Layout,
u.passwordLine("Password", password),
layout.Spacer{Height: sectionGap}.Layout,
detailLine(u.theme, "URL", item.URL),
layout.Spacer{Height: sectionGap}.Layout,
detailLine(u.theme, "Tags", strings.Join(item.Tags, ", ")),
layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyUser, "Copy User")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL")
}),
)
}
return layout.Flex{}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.copyUser, "Copy User")
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.copyPass, "Copy Password")
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.copyURL, "Copy URL")
return btn.Layout(gtx)
}),
)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(u.theme, item.Notes)
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(12)}.Layout,
u.historyPanel,
layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template")
}),
)
case appstate.SectionRecycleBin:
return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry")
default:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.editEntry, "Edit")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete")
}),
)
}
},
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
func (u *ui) banner(gtx layout.Context) layout.Dimensions {
banner := u.bannerSurface()
if banner.Kind == bannerNone {
return layout.Dimensions{}
}
bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255}
fg := accentColor
switch banner.Kind {
case bannerLoading:
bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255}
fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255}
case bannerError:
bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255}
fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255}
}
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), banner.Message)
lbl.Color = fg
return lbl.Layout(gtx)
})
})
}
func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions {
history := u.visibleHistory()
u.ensureHistoryClickables()
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "History")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
}
if len(history) == 0 {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "No history for this entry yet.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}))
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
for i := range history {
index := i
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.historyRow(gtx, &u.historyClicks[index], index, history[index])
}))
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
if selected, ok := u.selectedHistoryEntry(); ok {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Selected Version")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Path", strings.Join(selected.Path, " / "))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Username", selected.Username)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "URL", selected.URL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body2(u.theme, selected.Notes)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions {
for click.Clicked(gtx) {
_ = u.selectHistoryVersion(index)
}
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), fmt.Sprintf("Version %d", index))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), item.Username)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), item.URL)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body2(u.theme, item.Notes)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if index == u.selectedHistoryIndex {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op())
paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(row),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionRecycleBin {
lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
u.syncCurrentPath()
displayPath := u.displayPath()
crumbs := append([]string{"/"}, append([]string{}, displayPath...)...)
if u.state.Section == appstate.SectionTemplates {
crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(crumbs)*2)
for i, name := range crumbs {
index := i
label := name
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.breadcrumbs[index].Clicked(gtx) {
if index == 0 {
root := u.hiddenVaultRoot()
if root == "" {
u.setCurrentPath(nil)
} else {
u.setCurrentPath([]string{root})
}
} else {
nextPath := crumbs[1 : index+1]
root := u.hiddenVaultRoot()
if root != "" {
nextPath = append([]string{root}, nextPath...)
}
u.setCurrentPath(nextPath)
}
u.filter()
}
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index)))
btn.TextSize = unit.Sp(12)
btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10}
return btn.Layout(gtx)
}))
if i < len(crumbs)-1 {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "/")
lbl.Color = mutedColor
return layout.UniformInset(unit.Dp(6)).Layout(gtx, lbl.Layout)
}))
}
}
return children
}()...)
}
func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
groups := append([]string{}, u.childGroups()...)
if len(u.groupClicks) < len(groups) {
u.groupClicks = make([]widget.Clickable, len(groups))
}
if len(groups) == 0 {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(groups)*2)
for i, group := range groups {
idx := i
name := group
children = append(children, layout.Rigid(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()
}
btn := material.Button(u.theme, &u.groupClicks[idx], name)
btn.Background = color.NRGBA{R: 241, G: 236, B: 227, A: 255}
btn.Color = accentColor
btn.TextSize = unit.Sp(12)
return btn.Layout(gtx)
}))
if i < len(groups)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}
func detailLine(th *material.Theme, label, value string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(16), value)
return lbl.Layout(gtx)
}),
)
}
}
func (u *ui) passwordLine(label, value string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
icon := u.eyeIcon
desc := "Show password"
if u.showPassword {
icon = u.eyeOffIcon
desc = "Hide password"
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), value)
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.togglePasswordInline, icon, desc)
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
}),
)
}
}
func (u *ui) detailPasswordValue() string {
item, ok := u.selectedEntry()
if !ok {
return ""
}
if u.showPassword {
return item.Password
}
return strings.Repeat("•", max(8, len(item.Password)))
}
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, w)
})
}
func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, w)
})
}
func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions {
appearance := fieldFocusAppearance(gtx.Metric, focused)
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = appearance.MinHeight
}
gtx.Constraints.Min = size
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{R: 255, G: 253, B: 249, A: 255}, clip.Rect{Max: size}.Op())
return layout.Dimensions{Size: size}
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return drawFocusOutline(gtx, appearance, size)
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
min := gtx.Constraints.Min
gtx.Constraints.Min = image.Point{}
dims := w(gtx)
if dims.Size.X < min.X {
dims.Size.X = min.X
}
if dims.Size.Y < min.Y {
dims.Size.Y = min.Y
}
if dims.Size.Y < appearance.MinHeight {
dims.Size.Y = appearance.MinHeight
}
return dims
}),
)
}
func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
btn := material.Button(th, click, label)
btn.Background, btn.Color = buttonFocusColors(false)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(15)
return btn.Layout(gtx)
}
func fill(c color.NRGBA) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, c, clip.Rect{Max: gtx.Constraints.Min}.Op())
return layout.Dimensions{Size: gtx.Constraints.Min}
}
}
func main() {
mode := flag.String("mode", "", "window mode: desktop or phone")
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", "desktop")
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
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)
w.Option(
app.Title(productName),
app.Size(width, height),
)
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir)); err != nil {
panic(err)
}
os.Exit(0)
}()
app.Main()
}
func run(w *app.Window, mode string, paths statePaths) error {
var ops op.Ops
ui := newUI(mode, paths)
for {
e := w.Event()
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
ui.layout(gtx)
e.Frame(gtx.Ops)
}
}
}
type uiSession struct {
model vault.Model
locked bool
}
func (s *uiSession) HasVault() bool {
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
}
func (s *uiSession) IsLocked() bool {
return s.locked
}
func (s *uiSession) IsRemote() bool {
return false
}
func (s *uiSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *uiSession) Replace(model vault.Model) {
s.model = model
}
func (s *uiSession) Lock() error {
s.locked = true
return nil
}
func (s *uiSession) Unlock(vault.MasterKey) error {
if !s.locked {
return nil
}
s.locked = false
return nil
}
func pickExistingFile() (string, error) {
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
return path, nil
}
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
return path, nil
}
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
}
func runFilePicker(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
cmd := exec.Command(name, args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}