Simplify KeePassGO desktop vault workflow

This commit is contained in:
Joe Julian
2026-03-29 14:50:33 -07:00
parent 62fc343ecf
commit 4a629c16bd
6 changed files with 777 additions and 281 deletions
+19
View File
@@ -48,6 +48,11 @@ type SaveableSession interface {
Save() error Save() error
} }
type SynchronizableSession interface {
CurrentSession
Synchronize() error
}
type CreateableSession interface { type CreateableSession interface {
CurrentSession CurrentSession
Create(vault.Model, vault.MasterKey) error Create(vault.Model, vault.MasterKey) error
@@ -510,6 +515,20 @@ func (s *State) Save() error {
return nil 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 { func (s *State) CreateVault(key vault.MasterKey) error {
session, ok := s.Session.(CreateableSession) session, ok := s.Session.(CreateableSession)
if !ok { if !ok {
+278 -65
View File
@@ -7,6 +7,8 @@ import (
"image" "image"
"image/color" "image/color"
"os" "os"
"os/exec"
"path/filepath"
"slices" "slices"
"strings" "strings"
@@ -57,6 +59,12 @@ type uiSurface struct {
Locked bool Locked bool
} }
type sessionStatus interface {
HasVault() bool
IsLocked() bool
IsRemote() bool
}
type attachmentItem struct { type attachmentItem struct {
Name string Name string
Size int Size int
@@ -102,6 +110,11 @@ type ui struct {
saveAsVault widget.Clickable saveAsVault widget.Clickable
openRemote widget.Clickable openRemote widget.Clickable
changeMasterKey widget.Clickable changeMasterKey widget.Clickable
synchronizeVault widget.Clickable
editEntry widget.Clickable
cancelEdit widget.Clickable
pickVaultPath widget.Clickable
pickKeyFile widget.Clickable
addEntry widget.Clickable addEntry widget.Clickable
saveEntry widget.Clickable saveEntry widget.Clickable
duplicateEntry widget.Clickable duplicateEntry widget.Clickable
@@ -125,16 +138,12 @@ type ui struct {
showRecycle widget.Clickable showRecycle widget.Clickable
showLocalLifecycle widget.Clickable showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable showRemoteLifecycle widget.Clickable
masterKeyPasswordOnly widget.Clickable
masterKeyKeyFileOnly widget.Clickable
masterKeyComposite widget.Clickable
entryClicks []widget.Clickable entryClicks []widget.Clickable
historyClicks []widget.Clickable historyClicks []widget.Clickable
attachmentClicks []widget.Clickable attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable breadcrumbs []widget.Clickable
groupClicks []widget.Clickable groupClicks []widget.Clickable
state appstate.State state appstate.State
masterKeyMode vault.MasterKeyMode
visible []entry visible []entry
currentPath []string currentPath []string
syncedPath []string syncedPath []string
@@ -153,6 +162,8 @@ type ui struct {
loadingMessage string loadingMessage string
lifecycleMode string lifecycleMode string
keyboardFocus focusID keyboardFocus focusID
defaultSaveAsPath string
editingEntry bool
} }
var ( var (
@@ -225,9 +236,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
List: layout.List{Axis: layout.Vertical}, List: layout.List{Axis: layout.Vertical},
}, },
state: appstate.State{}, state: appstate.State{},
masterKeyMode: vault.MasterKeyModePasswordOnly,
selectedHistoryIndex: -1, selectedHistoryIndex: -1,
lifecycleMode: "local", lifecycleMode: "local",
defaultSaveAsPath: defaultSaveAsPath(),
} }
u.state.Session = sess u.state.Session = sess
u.phoneSplit.Value = 0.46 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 { func (u *ui) selectedAttachmentItems() []attachmentItem {
item, ok := u.selectedEntry() item, ok := u.selectedEntry()
if !ok || len(item.Attachments) == 0 { if !ok || len(item.Attachments) == 0 {
@@ -370,27 +389,12 @@ func (u *ui) ensureHistoryClickables() {
func (u *ui) currentMasterKey() (vault.MasterKey, error) { func (u *ui) currentMasterKey() (vault.MasterKey, error) {
password := u.masterPassword.Text() password := u.masterPassword.Text()
path := strings.TrimSpace(u.keyFilePath.Text()) path := strings.TrimSpace(u.keyFilePath.Text())
if password == "" && path == "" {
switch u.masterKeyMode { return vault.MasterKey{}, fmt.Errorf("master password or key file is required")
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 == "" { 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 return vault.MasterKey{Password: password}, nil
} }
}
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -406,9 +410,7 @@ func (u *ui) currentMasterKey() (vault.MasterKey, error) {
}, nil }, nil
} }
func (u *ui) setMasterKeyMode(mode vault.MasterKeyMode) { func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {}
u.masterKeyMode = mode
}
func (u *ui) createVaultAction() error { func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey() key, err := u.currentMasterKey()
@@ -418,7 +420,14 @@ func (u *ui) createVaultAction() error {
if err := u.state.CreateVault(key); err != nil { if err := u.state.CreateVault(key); err != nil {
return err 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.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
@@ -436,6 +445,7 @@ func (u *ui) openVaultAction() error {
return err return err
} }
u.currentPath = append([]string(nil), u.state.CurrentPath...) u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
@@ -449,14 +459,11 @@ func (u *ui) saveAction() error {
} }
func (u *ui) saveAsAction() error { func (u *ui) saveAsAction() error {
path := strings.TrimSpace(u.saveAsPath.Text()) path := u.saveAsTargetPath()
if path == "" {
return errors.New(errSaveAsPathRequired)
}
if err := u.state.SaveAs(path); err != nil { if err := u.state.SaveAs(path); err != nil {
return err return err
} }
u.vaultPath.SetText(path)
u.filter() u.filter()
return nil 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 { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err return err
} }
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
@@ -484,6 +492,7 @@ func (u *ui) lockAction() error {
} }
u.currentPath = append([]string(nil), u.state.CurrentPath...) u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.showPassword = false u.showPassword = false
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
@@ -497,6 +506,7 @@ func (u *ui) unlockAction() error {
return err return err
} }
u.currentPath = append([]string(nil), u.state.CurrentPath...) u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
@@ -509,6 +519,22 @@ func (u *ui) changeMasterKeyAction() error {
return u.state.ChangeMasterKey(key) 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) { func (u *ui) runAction(label string, action func() error) {
u.loadingMessage = actionLoadingLabel(label) u.loadingMessage = actionLoadingLabel(label)
if err := action(); err != nil { if err := action(); err != nil {
@@ -572,6 +598,37 @@ func (u *ui) sessionSurface() uiSurface {
return 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 { func (u *ui) listEmptyMessage() string {
if surface := u.sessionSurface(); surface.Locked { if surface := u.sessionSurface(); surface.Locked {
return "Unlock the vault to browse entries and groups." 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) { for u.changeMasterKey.Clicked(gtx) {
u.runAction("change master key", u.changeMasterKeyAction) u.runAction("change master key", u.changeMasterKeyAction)
} }
for u.synchronizeVault.Clicked(gtx) {
u.runAction("synchronize vault", u.synchronizeAction)
}
for u.unlockVault.Clicked(gtx) { for u.unlockVault.Clicked(gtx) {
u.runAction("unlock vault", u.unlockAction) 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) { for u.showEntries.Clicked(gtx) {
u.showEntriesSection() u.showEntriesSection()
} }
@@ -691,10 +742,25 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.lockVault.Clicked(gtx) { for u.lockVault.Clicked(gtx) {
u.runAction("lock vault", u.lockAction) 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) { for u.addEntry.Clicked(gtx) {
u.state.BeginNewEntry() u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor() u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.editingEntry = true
} }
for u.saveEntry.Clicked(gtx) { for u.saveEntry.Clicked(gtx) {
u.runAction("save entry", u.saveEntryAction) 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 { 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)) { if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) {
u.phoneSpan = gtx.Constraints.Max.Y u.phoneSpan = gtx.Constraints.Max.Y
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) 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 lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(u.headerActions),
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}),
) )
}), }),
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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.lifecycleControls), 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.feedbackBanner), layout.Rigid(u.feedbackBanner),
) )
}) })
@@ -843,18 +916,43 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) 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),
)
}),
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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock") btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx) return btn.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(u.feedbackBanner),
)
})
} }
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
@@ -867,13 +965,33 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
u.ensureNavClickables() u.ensureNavClickables()
return panel(gtx, func(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 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(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(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(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(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" { 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(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
label := "Add Entry" label := "Add Entry"
if u.mode == "phone" { if u.mode == "phone" {
label = "+ Add Entry" label = "+ Add Entry"
@@ -1065,16 +1186,28 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
panel = compactCard panel = compactCard
} }
return panel(gtx, func(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions {
item, ok := u.selectedEntry() if u.isVaultLocked() {
if !ok {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
surface := u.sessionSurface() lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault")
title := surface.Title lbl.Color = accentColor
if title == "" { return lbl.Layout(gtx)
title = "Entry details" }),
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),
)
} }
lbl := material.Label(u.theme, unit.Sp(18), title) 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 lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
@@ -1084,7 +1217,20 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) 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), layout.Rigid(u.entryEditorPanel),
) )
} }
@@ -1156,7 +1302,40 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
layout.Spacer{Height: unit.Dp(12)}.Layout, layout.Spacer{Height: unit.Dp(12)}.Layout,
u.historyPanel, u.historyPanel,
layout.Spacer{Height: unit.Dp(12)}.Layout, 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 material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx) return rows[i](gtx)
@@ -1310,7 +1489,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
} }
u.syncCurrentPath() 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 { if u.state.Section == appstate.SectionTemplates {
crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) 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.currentPath = append([]string(nil), u.state.CurrentPath...)
u.filter() 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.Background = color.NRGBA{R: 241, G: 236, B: 227, A: 255}
btn.Color = accentColor btn.Color = accentColor
btn.TextSize = unit.Sp(12) btn.TextSize = unit.Sp(12)
@@ -1575,6 +1754,18 @@ type uiSession struct {
locked bool 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) { func (s *uiSession) Current() (vault.Model, error) {
if s.locked { if s.locked {
return vault.Model{}, session.ErrLocked return vault.Model{}, session.ErrLocked
@@ -1598,3 +1789,25 @@ func (s *uiSession) Unlock(vault.MasterKey) error {
s.locked = false s.locked = false
return nil 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 { tests := []struct {
name string name string
mode vault.MasterKeyMode
password string password string
keyFile string keyFile string
wantError string wantError string
}{ }{
{ {
name: "password mode requires password", name: "requires either password or key file",
mode: vault.MasterKeyModePasswordOnly, wantError: "master password or key file is required",
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",
}, },
} }
@@ -437,7 +424,6 @@ func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) {
t.Parallel() t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}) u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(tt.mode)
u.masterPassword.SetText(tt.password) u.masterPassword.SetText(tt.password)
u.keyFilePath.SetText(tt.keyFile) 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: "Git Server", 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) { func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1942,28 +1966,32 @@ func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) {
u.runAction("create vault", u.createVaultAction) u.runAction("create vault", u.createVaultAction)
u.runAction("save vault", u.saveAction) u.runAction("save vault", u.saveAction)
if got := u.state.StatusMessage; got != "" { if got := u.state.StatusMessage; got != "save vault complete" {
t.Fatalf("status after failed save = %q, want empty", got) t.Fatalf("status after save = %q, want %q", got, "save vault complete")
} }
if got := u.state.ErrorMessage; !strings.Contains(got, session.ErrNoPath.Error()) { if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after failed save = %q, want %q", got, session.ErrNoPath.Error()) 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() t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}) u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple") 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("create vault", u.createVaultAction)
u.runAction("save-as vault", u.saveAsAction) u.runAction("save-as vault", u.saveAsAction)
if got := u.state.StatusMessage; got != "" { if got := u.state.StatusMessage; got != "save-as vault complete" {
t.Fatalf("status after failed save-as = %q, want empty", got) 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" { if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after failed save-as = %q, want %q", got, "save-as path is required") 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" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"reflect"
"slices"
"strings"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav" "git.julianfamily.org/keepassgo/webdav"
@@ -40,6 +44,18 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
return nil 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 { func (m *Manager) Open(path string, key vault.MasterKey) error {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -114,6 +130,17 @@ func (m *Manager) SaveRemote() error {
return nil 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 { func (m *Manager) SaveAs(path string) error {
if err := m.saveToPath(path); err != nil { if err := m.saveToPath(path); err != nil {
return err return err
@@ -203,6 +230,9 @@ func (m *Manager) saveToPath(path string) error {
return err 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 { if err := os.WriteFile(path, encoded, 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err) return fmt.Errorf("write %s: %w", path, err)
} }
@@ -222,3 +252,223 @@ func (m *Manager) persistableBytes() ([]byte, error) {
} }
return encoded.Bytes(), nil 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 { if err := u.state.UpsertEntry(entry); err != nil {
return err return err
} }
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
@@ -160,6 +161,7 @@ func (u *ui) saveTemplateAction() error {
if err := u.state.UpsertTemplate(entry); err != nil { if err := u.state.UpsertTemplate(entry); err != nil {
return err return err
} }
u.editingEntry = false
u.filter() u.filter()
return nil return nil
} }
+69 -85
View File
@@ -12,7 +12,6 @@ import (
) )
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
surface := u.sessionSurface()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(labeledEditorHelp(u.theme, "Master Password", "Leave blank if this vault is protected by key file only.", &u.masterPassword, true)),
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(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
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(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" { 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, 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(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(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(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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, return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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") return tonedButton(gtx, u.theme, &u.openVault, "Open Vault")
}), }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" { return tonedButton(gtx, u.theme, &u.createVault, "New Vault")
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.saveAsVault, "Save As")
}), }),
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 { 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.Dimensions{}
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -146,6 +103,11 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") return tonedButton(gtx, u.theme, &u.createGroup, "Create Group")
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
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(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group") return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group")
@@ -157,12 +119,12 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
) )
}), }),
) )
}),
)
} }
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 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(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), 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(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))), layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), 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(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password")
}), }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") 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")
}
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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 { 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 func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 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(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help) 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( func labeledEditorWithFocus(
th *material.Theme, th *material.Theme,
label string, label string,