diff --git a/appstate/state.go b/appstate/state.go index 0941244..138150e 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -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 { diff --git a/main.go b/main.go index fccad3e..5d3a502 100644 --- a/main.go +++ b/main.go @@ -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 +} diff --git a/main_test.go b/main_test.go index 5da2de9..f5be8ce 100644 --- a/main_test.go +++ b/main_test.go @@ -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) } }) diff --git a/session/session.go b/session/session.go index 2abfac9..5c8cf88 100644 --- a/session/session.go +++ b/session/session.go @@ -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") +} diff --git a/ui_editor.go b/ui_editor.go index 0cff4ec..c264dfe 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -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 } diff --git a/ui_forms.go b/ui_forms.go index 9b7a075..608e854 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -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,