Simplify KeePassGO desktop vault workflow

This commit is contained in:
Joe Julian
2026-03-29 14:50:33 -07:00
parent 7e2e396264
commit fa75908552
6 changed files with 777 additions and 281 deletions
+19
View File
@@ -48,6 +48,11 @@ type SaveableSession interface {
Save() error
}
type SynchronizableSession interface {
CurrentSession
Synchronize() error
}
type CreateableSession interface {
CurrentSession
Create(vault.Model, vault.MasterKey) error
@@ -510,6 +515,20 @@ func (s *State) Save() error {
return nil
}
func (s *State) Synchronize() error {
session, ok := s.Session.(SynchronizableSession)
if !ok {
return fmt.Errorf("session is not synchronizable")
}
if err := session.Synchronize(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) CreateVault(key vault.MasterKey) error {
session, ok := s.Session.(CreateableSession)
if !ok {
+371 -158
View File
@@ -7,6 +7,8 @@ import (
"image"
"image/color"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
@@ -57,102 +59,111 @@ type uiSurface struct {
Locked bool
}
type sessionStatus interface {
HasVault() bool
IsLocked() bool
IsRemote() bool
}
type attachmentItem struct {
Name string
Size int
}
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
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
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
togglePasswordInline widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable
masterKeyPasswordOnly widget.Clickable
masterKeyKeyFileOnly widget.Clickable
masterKeyComposite widget.Clickable
entryClicks []widget.Clickable
historyClicks []widget.Clickable
attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable
groupClicks []widget.Clickable
state appstate.State
masterKeyMode vault.MasterKeyMode
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
clipboardWriter clipboard.Writer
loadingMessage string
lifecycleMode string
keyboardFocus focusID
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
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
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
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
clipboardWriter clipboard.Writer
loadingMessage string
lifecycleMode string
keyboardFocus focusID
defaultSaveAsPath string
editingEntry bool
}
var (
@@ -225,9 +236,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
List: layout.List{Axis: layout.Vertical},
},
state: appstate.State{},
masterKeyMode: vault.MasterKeyModePasswordOnly,
selectedHistoryIndex: -1,
lifecycleMode: "local",
defaultSaveAsPath: defaultSaveAsPath(),
}
u.state.Session = sess
u.phoneSplit.Value = 0.46
@@ -253,6 +264,14 @@ func (u *ui) filter() {
}
}
func defaultSaveAsPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil || strings.TrimSpace(cacheDir) == "" {
cacheDir = os.TempDir()
}
return filepath.Join(cacheDir, "keepassgo", "vault.kdbx")
}
func (u *ui) selectedAttachmentItems() []attachmentItem {
item, ok := u.selectedEntry()
if !ok || len(item.Attachments) == 0 {
@@ -370,26 +389,11 @@ func (u *ui) ensureHistoryClickables() {
func (u *ui) currentMasterKey() (vault.MasterKey, error) {
password := u.masterPassword.Text()
path := strings.TrimSpace(u.keyFilePath.Text())
switch u.masterKeyMode {
case vault.MasterKeyModeKeyFileOnly:
if path == "" {
return vault.MasterKey{}, fmt.Errorf("key file is required")
}
case vault.MasterKeyModePasswordAndKeyFile:
if password == "" {
return vault.MasterKey{}, fmt.Errorf("master password is required")
}
if path == "" {
return vault.MasterKey{}, fmt.Errorf("key file is required")
}
default:
if path == "" {
if password == "" {
return vault.MasterKey{}, fmt.Errorf("master password is required")
}
return vault.MasterKey{Password: password}, nil
}
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)
@@ -406,9 +410,7 @@ func (u *ui) currentMasterKey() (vault.MasterKey, error) {
}, nil
}
func (u *ui) setMasterKeyMode(mode vault.MasterKeyMode) {
u.masterKeyMode = mode
}
func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {}
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
@@ -418,7 +420,14 @@ func (u *ui) createVaultAction() error {
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.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter()
return nil
}
@@ -436,6 +445,7 @@ func (u *ui) openVaultAction() error {
return err
}
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter()
return nil
}
@@ -449,14 +459,11 @@ func (u *ui) saveAction() error {
}
func (u *ui) saveAsAction() error {
path := strings.TrimSpace(u.saveAsPath.Text())
if path == "" {
return errors.New(errSaveAsPathRequired)
}
path := u.saveAsTargetPath()
if err := u.state.SaveAs(path); err != nil {
return err
}
u.vaultPath.SetText(path)
u.filter()
return nil
}
@@ -474,6 +481,7 @@ func (u *ui) openRemoteAction() error {
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err
}
u.editingEntry = false
u.filter()
return nil
}
@@ -484,6 +492,7 @@ func (u *ui) lockAction() error {
}
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.showPassword = false
u.editingEntry = false
u.filter()
return nil
}
@@ -497,6 +506,7 @@ func (u *ui) unlockAction() error {
return err
}
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter()
return nil
}
@@ -509,6 +519,22 @@ func (u *ui) changeMasterKeyAction() error {
return u.state.ChangeMasterKey(key)
}
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) runAction(label string, action func() error) {
u.loadingMessage = actionLoadingLabel(label)
if err := action(); err != nil {
@@ -572,6 +598,37 @@ func (u *ui) sessionSurface() uiSurface {
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) 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."
@@ -661,18 +718,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
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.masterKeyPasswordOnly.Clicked(gtx) {
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
}
for u.masterKeyKeyFileOnly.Clicked(gtx) {
u.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
}
for u.masterKeyComposite.Clicked(gtx) {
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
}
for u.showEntries.Clicked(gtx) {
u.showEntriesSection()
}
@@ -691,10 +742,25 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
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 u.addEntry.Clicked(gtx) {
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.editingEntry = true
}
for u.saveEntry.Clicked(gtx) {
u.runAction("save entry", u.saveEntryAction)
@@ -778,6 +844,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
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)
@@ -821,15 +890,19 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.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),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.lifecycleControls),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.feedbackBanner),
)
})
@@ -843,20 +916,45 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.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),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(u.lifecycleControls),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(u.feedbackBanner),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
if u.isVaultLocked() {
btn := material.Button(u.theme, &u.unlockVault, "Unlock")
return btn.Layout(gtx)
}
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)
@@ -867,13 +965,33 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
u.ensureNavClickables()
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(u.sectionBar),
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(u.pathBar),
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(u.groupBar),
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(u.groupControls),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
return layout.Dimensions{}
}
return u.groupControls(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
@@ -888,6 +1006,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}),
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 = "+ Add Entry"
@@ -1065,16 +1186,28 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
item, ok := u.selectedEntry()
if !ok {
if u.isVaultLocked() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
surface := u.sessionSurface()
title := surface.Title
if title == "" {
title = "Entry details"
}
lbl := material.Label(u.theme, unit.Sp(18), title)
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)
}),
@@ -1084,7 +1217,20 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
)
}
if u.editingEntry {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(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.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.entryEditorPanel),
)
}
@@ -1156,7 +1302,40 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
layout.Spacer{Height: unit.Dp(12)}.Layout,
u.historyPanel,
layout.Spacer{Height: unit.Dp(12)}.Layout,
u.entryEditorPanel,
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)
@@ -1310,7 +1489,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
}
u.syncCurrentPath()
crumbs := append([]string{"Vault"}, append([]string{}, u.currentPath...)...)
crumbs := append([]string{"All Entries"}, append([]string{}, u.currentPath...)...)
if u.state.Section == appstate.SectionTemplates {
crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...)
}
@@ -1365,7 +1544,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
}
btn := material.Button(u.theme, &u.groupClicks[idx], "Folder: "+name)
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)
@@ -1575,6 +1754,18 @@ type uiSession struct {
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
@@ -1598,3 +1789,25 @@ func (s *uiSession) Unlock(vault.MasterKey) error {
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
}
+53 -25
View File
@@ -408,26 +408,13 @@ func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) {
tests := []struct {
name string
mode vault.MasterKeyMode
password string
keyFile string
wantError string
}{
{
name: "password mode requires password",
mode: vault.MasterKeyModePasswordOnly,
wantError: "master password is required",
},
{
name: "key file mode requires path",
mode: vault.MasterKeyModeKeyFileOnly,
wantError: "key file is required",
},
{
name: "composite mode requires password",
mode: vault.MasterKeyModePasswordAndKeyFile,
keyFile: filepath.Join("/tmp", "ignored.key"),
wantError: "master password is required",
name: "requires either password or key file",
wantError: "master password or key file is required",
},
}
@@ -437,7 +424,6 @@ func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(tt.mode)
u.masterPassword.SetText(tt.password)
u.keyFilePath.SetText(tt.keyFile)
@@ -1670,6 +1656,44 @@ func TestUIUsesKeePassGOProductCopy(t *testing.T) {
}
}
func TestUIShowsLifecycleSetupOnlyBeforeVaultIsOpened(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
if !u.shouldShowLifecycleSetup() {
t.Fatal("shouldShowLifecycleSetup() = false, want true before opening a vault")
}
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
if u.shouldShowLifecycleSetup() {
t.Fatal("shouldShowLifecycleSetup() = true, want false after opening a vault")
}
}
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{{ID: "1", Title: "Vault Console", Path: []string{"Root"}}},
})
u.filter()
u.state.SelectedEntryID = "1"
if u.editingEntry {
t.Fatal("editingEntry = true, want false by default")
}
u.editingEntry = true
u.loadSelectedEntryIntoEditor()
if !u.editingEntry {
t.Fatal("editingEntry = false, want true after entering edit mode")
}
}
func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) {
t.Parallel()
@@ -1942,28 +1966,32 @@ func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) {
u.runAction("create vault", u.createVaultAction)
u.runAction("save vault", u.saveAction)
if got := u.state.StatusMessage; got != "" {
t.Fatalf("status after failed save = %q, want empty", got)
if got := u.state.StatusMessage; got != "save vault complete" {
t.Fatalf("status after save = %q, want %q", got, "save vault complete")
}
if got := u.state.ErrorMessage; !strings.Contains(got, session.ErrNoPath.Error()) {
t.Fatalf("error after failed save = %q, want %q", got, session.ErrNoPath.Error())
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after save = %q, want empty", got)
}
})
t.Run("save-as without target path", func(t *testing.T) {
t.Run("save-as uses default target path", func(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.defaultSaveAsPath = filepath.Join(t.TempDir(), "default-save-as.kdbx")
u.runAction("create vault", u.createVaultAction)
u.runAction("save-as vault", u.saveAsAction)
if got := u.state.StatusMessage; got != "" {
t.Fatalf("status after failed save-as = %q, want empty", got)
if got := u.state.StatusMessage; got != "save-as vault complete" {
t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete")
}
if got := u.state.ErrorMessage; got != "save-as path is required" {
t.Fatalf("error after failed save-as = %q, want %q", got, "save-as path is required")
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after save-as = %q, want empty", got)
}
if _, err := os.Stat(u.defaultSaveAsPath); err != nil {
t.Fatalf("Stat(defaultSaveAsPath) error = %v", err)
}
})
+250
View File
@@ -5,6 +5,10 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
@@ -40,6 +44,18 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
return nil
}
func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
}
func (m *Manager) IsLocked() bool {
return m.locked
}
func (m *Manager) IsRemote() bool {
return m.remoteClient != nil && m.remotePath != ""
}
func (m *Manager) Open(path string, key vault.MasterKey) error {
content, err := os.ReadFile(path)
if err != nil {
@@ -114,6 +130,17 @@ func (m *Manager) SaveRemote() error {
return nil
}
func (m *Manager) Synchronize() error {
switch {
case m.remoteClient != nil && m.remotePath != "":
return m.synchronizeRemote()
case m.path != "":
return m.synchronizeLocal()
default:
return ErrNoPath
}
}
func (m *Manager) SaveAs(path string) error {
if err := m.saveToPath(path); err != nil {
return err
@@ -203,6 +230,9 @@ func (m *Manager) saveToPath(path string) error {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create parent dir for %s: %w", path, err)
}
if err := os.WriteFile(path, encoded, 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
@@ -222,3 +252,223 @@ func (m *Manager) persistableBytes() ([]byte, error) {
}
return encoded.Bytes(), nil
}
func (m *Manager) synchronizeLocal() error {
current, err := m.currentModelForPersistence()
if err != nil {
return err
}
content, err := os.ReadFile(m.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return m.saveToPath(m.path)
}
return fmt.Errorf("read %s: %w", m.path, err)
}
latest, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), m.key)
if err != nil {
return fmt.Errorf("open %s for synchronize: %w", m.path, err)
}
base, err := m.baseModel()
if err != nil {
return err
}
merged := mergeModels(base, current, latest)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
return fmt.Errorf("encode synchronized vault: %w", err)
}
if err := os.WriteFile(m.path, encoded.Bytes(), 0o600); err != nil {
return fmt.Errorf("write synchronized %s: %w", m.path, err)
}
m.model = merged
m.config = config
m.encoded = encoded.Bytes()
m.locked = false
return nil
}
func (m *Manager) synchronizeRemote() error {
current, err := m.currentModelForPersistence()
if err != nil {
return err
}
content, version, err := m.remoteClient.Open(m.remotePath)
if err != nil {
return fmt.Errorf("open remote %s for synchronize: %w", m.remotePath, err)
}
latest, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), m.key)
if err != nil {
return fmt.Errorf("decode remote %s for synchronize: %w", m.remotePath, err)
}
base, err := m.baseModel()
if err != nil {
return err
}
merged := mergeModels(base, current, latest)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
return fmt.Errorf("encode synchronized remote vault: %w", err)
}
nextVersion, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), version)
if err != nil {
return fmt.Errorf("save synchronized remote %s: %w", m.remotePath, err)
}
m.model = merged
m.config = config
m.encoded = encoded.Bytes()
m.remoteVersion = nextVersion
m.locked = false
return nil
}
func (m *Manager) currentModelForPersistence() (vault.Model, error) {
if m.locked {
return vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
}
return m.model, nil
}
func (m *Manager) baseModel() (vault.Model, error) {
if len(m.encoded) == 0 {
return vault.Model{}, nil
}
model, err := vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
if err != nil {
return vault.Model{}, fmt.Errorf("decode baseline vault: %w", err)
}
return model, nil
}
func mergeModels(base, local, latest vault.Model) vault.Model {
merged := latest
merged.Entries = mergeEntrySet(base.Entries, local.Entries, latest.Entries)
merged.Templates = mergeEntrySet(base.Templates, local.Templates, latest.Templates)
merged.RecycleBin = mergeEntrySet(base.RecycleBin, local.RecycleBin, latest.RecycleBin)
merged.Groups = mergeGroups(base.Groups, local.Groups, latest.Groups)
return merged
}
func mergeEntrySet(base, local, latest []vault.Entry) []vault.Entry {
baseByID := mapEntries(base)
localByID := mapEntries(local)
latestByID := mapEntries(latest)
for id, current := range localByID {
original, hadBase := baseByID[id]
if !hadBase || !entriesEqual(original, current) {
latestByID[id] = current
}
}
for id := range baseByID {
if _, stillLocal := localByID[id]; stillLocal {
continue
}
delete(latestByID, id)
}
out := make([]vault.Entry, 0, len(latestByID))
for _, item := range latestByID {
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func mapEntries(entries []vault.Entry) map[string]vault.Entry {
out := make(map[string]vault.Entry, len(entries))
for _, item := range entries {
out[item.ID] = item
}
return out
}
func entriesEqual(a, b vault.Entry) bool {
return a.ID == b.ID &&
a.Title == b.Title &&
a.Username == b.Username &&
a.Password == b.Password &&
a.URL == b.URL &&
a.Notes == b.Notes &&
slices.Equal(a.Tags, b.Tags) &&
slices.Equal(a.Path, b.Path) &&
reflect.DeepEqual(a.History, b.History) &&
reflect.DeepEqual(a.Fields, b.Fields) &&
equalAttachments(a.Attachments, b.Attachments)
}
func equalAttachments(a, b map[string][]byte) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
if !slices.Equal(value, b[key]) {
return false
}
}
return true
}
func mergeGroups(base, local, latest [][]string) [][]string {
set := map[string][]string{}
for _, path := range latest {
set[pathKey(path)] = append([]string(nil), path...)
}
baseSet := map[string]bool{}
for _, path := range base {
baseSet[pathKey(path)] = true
}
localSet := map[string]bool{}
for _, path := range local {
key := pathKey(path)
localSet[key] = true
set[key] = append([]string(nil), path...)
}
for key := range baseSet {
if localSet[key] {
continue
}
delete(set, key)
}
out := make([][]string, 0, len(set))
for _, path := range set {
out = append(out, path)
}
slices.SortFunc(out, func(a, b []string) int {
joinedA := pathKey(a)
joinedB := pathKey(b)
switch {
case joinedA < joinedB:
return -1
case joinedA > joinedB:
return 1
default:
return 0
}
})
return out
}
func pathKey(path []string) string {
return strings.Join(path, "\x00")
}
+2
View File
@@ -107,6 +107,7 @@ func (u *ui) saveEntryAction() error {
if err := u.state.UpsertEntry(entry); err != nil {
return err
}
u.editingEntry = false
u.filter()
return nil
}
@@ -160,6 +161,7 @@ func (u *ui) saveTemplateAction() error {
if err := u.state.UpsertTemplate(entry); err != nil {
return err
}
u.editingEntry = false
u.filter()
return nil
}
+82 -98
View File
@@ -12,7 +12,6 @@ import (
)
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
surface := u.sessionSurface()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
@@ -26,34 +25,9 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "MASTER KEY MODE")
if surface.Locked {
lbl.Text += " • " + strings.ToUpper(surface.Message)
}
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(labeledEditorHelp(u.theme, "Master Password", "Leave blank if this vault is protected by key file only.", &u.masterPassword, true)),
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 tonedButton(gtx, u.theme, &u.masterKeyPasswordOnly, "Password Only")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.masterKeyKeyFileOnly, "Key File Only")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.masterKeyComposite, "Password + Key File")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Master Password", "Used alone or together with a key file to unlock the vault.", &u.masterPassword, true)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, false)),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
@@ -68,39 +42,22 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorHelp(u.theme, "Vault Path", "Local path to an existing .kdbx file to open.", &u.vaultPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Save As Path", "Local target path used when creating a new vault or saving a copy.", &u.saveAsPath, false)),
layout.Rigid(selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote Vault")
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.createVault, "New Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote")
}
return tonedButton(gtx, u.theme, &u.openVault, "Open Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveVault, "Save") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.saveAsVault, "Save As")
return tonedButton(gtx, u.theme, &u.createVault, "New Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.changeMasterKey, "Change Master Key")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") }),
)
}),
)
@@ -135,7 +92,7 @@ func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
}
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionRecycleBin {
if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -146,13 +103,20 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.createGroup, "Create Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group")
if len(u.currentPath) == 0 {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group")
}),
)
}),
)
}),
@@ -161,8 +125,6 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(u.theme, "ID", &u.entryID, false, u.isFocused(detailFocusID(detailFieldID)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
@@ -185,49 +147,33 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Custom Fields (key=value)", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))),
layout.Rigid(labeledEditorHelpFocus(u.theme, "Custom Fields", "One key=value pair per line. These fields are only saved when you save the entry.", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(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 layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelEdit, "Cancel")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionTemplates {
return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate")
}),
)
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.saveEntry, "Save Entry")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History")
}),
)
}
}
return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry")
}),
)
}),
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), "Generate Password only updates the form. Nothing is persisted until you save.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -283,9 +229,13 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens
}
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorHelpFocus(th, label, help, editor, sensitive, false)
}
func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, label, editor, sensitive)),
layout.Rigid(labeledEditorWithFocus(th, label, editor, sensitive, focused)),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
@@ -296,6 +246,40 @@ func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Ed
}
}
func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, click *widget.Clickable, buttonLabel string, sensitive bool) 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 {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, labeledEditor(th, label, editor, sensitive)),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, th, click, buttonLabel)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
}
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorHelp(u.theme, "Master Password", "Used alone or together with a key file to unlock the vault.", &u.masterPassword, true)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
}),
)
}
func labeledEditorWithFocus(
th *material.Theme,
label string,