Simplify KeePassGO desktop vault workflow
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -57,102 +59,111 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
type ui struct {
|
type ui struct {
|
||||||
mode string
|
mode string
|
||||||
theme *material.Theme
|
theme *material.Theme
|
||||||
search widget.Editor
|
search widget.Editor
|
||||||
vaultPath widget.Editor
|
vaultPath widget.Editor
|
||||||
saveAsPath widget.Editor
|
saveAsPath widget.Editor
|
||||||
remoteBaseURL widget.Editor
|
remoteBaseURL widget.Editor
|
||||||
remotePath widget.Editor
|
remotePath widget.Editor
|
||||||
remoteUsername widget.Editor
|
remoteUsername widget.Editor
|
||||||
remotePassword widget.Editor
|
remotePassword widget.Editor
|
||||||
masterPassword widget.Editor
|
masterPassword widget.Editor
|
||||||
keyFilePath widget.Editor
|
keyFilePath widget.Editor
|
||||||
entryID widget.Editor
|
entryID widget.Editor
|
||||||
entryTitle widget.Editor
|
entryTitle widget.Editor
|
||||||
entryUsername widget.Editor
|
entryUsername widget.Editor
|
||||||
entryPassword widget.Editor
|
entryPassword widget.Editor
|
||||||
entryURL widget.Editor
|
entryURL widget.Editor
|
||||||
entryNotes widget.Editor
|
entryNotes widget.Editor
|
||||||
entryTags widget.Editor
|
entryTags widget.Editor
|
||||||
entryPath widget.Editor
|
entryPath widget.Editor
|
||||||
entryFields widget.Editor
|
entryFields widget.Editor
|
||||||
historyIndex widget.Editor
|
historyIndex widget.Editor
|
||||||
groupName widget.Editor
|
groupName widget.Editor
|
||||||
passwordProfile widget.Editor
|
passwordProfile widget.Editor
|
||||||
attachmentName widget.Editor
|
attachmentName widget.Editor
|
||||||
attachmentPath widget.Editor
|
attachmentPath widget.Editor
|
||||||
exportAttachmentPath widget.Editor
|
exportAttachmentPath widget.Editor
|
||||||
list widget.List
|
list widget.List
|
||||||
detailList widget.List
|
detailList widget.List
|
||||||
copyUser widget.Clickable
|
copyUser widget.Clickable
|
||||||
copyPass widget.Clickable
|
copyPass widget.Clickable
|
||||||
copyURL widget.Clickable
|
copyURL widget.Clickable
|
||||||
lockVault widget.Clickable
|
lockVault widget.Clickable
|
||||||
unlockVault widget.Clickable
|
unlockVault widget.Clickable
|
||||||
createVault widget.Clickable
|
createVault widget.Clickable
|
||||||
openVault widget.Clickable
|
openVault widget.Clickable
|
||||||
saveVault widget.Clickable
|
saveVault widget.Clickable
|
||||||
saveAsVault widget.Clickable
|
saveAsVault widget.Clickable
|
||||||
openRemote widget.Clickable
|
openRemote widget.Clickable
|
||||||
changeMasterKey widget.Clickable
|
changeMasterKey widget.Clickable
|
||||||
addEntry widget.Clickable
|
synchronizeVault widget.Clickable
|
||||||
saveEntry widget.Clickable
|
editEntry widget.Clickable
|
||||||
duplicateEntry widget.Clickable
|
cancelEdit widget.Clickable
|
||||||
deleteEntry widget.Clickable
|
pickVaultPath widget.Clickable
|
||||||
restoreEntry widget.Clickable
|
pickKeyFile widget.Clickable
|
||||||
saveTemplate widget.Clickable
|
addEntry widget.Clickable
|
||||||
deleteTemplate widget.Clickable
|
saveEntry widget.Clickable
|
||||||
instantiateTemplate widget.Clickable
|
duplicateEntry widget.Clickable
|
||||||
addAttachment widget.Clickable
|
deleteEntry widget.Clickable
|
||||||
replaceAttachment widget.Clickable
|
restoreEntry widget.Clickable
|
||||||
removeAttachment widget.Clickable
|
saveTemplate widget.Clickable
|
||||||
exportAttachment widget.Clickable
|
deleteTemplate widget.Clickable
|
||||||
restoreHistory widget.Clickable
|
instantiateTemplate widget.Clickable
|
||||||
generatePassword widget.Clickable
|
addAttachment widget.Clickable
|
||||||
createGroup widget.Clickable
|
replaceAttachment widget.Clickable
|
||||||
renameGroup widget.Clickable
|
removeAttachment widget.Clickable
|
||||||
deleteGroup widget.Clickable
|
exportAttachment widget.Clickable
|
||||||
togglePasswordInline widget.Clickable
|
restoreHistory widget.Clickable
|
||||||
showEntries widget.Clickable
|
generatePassword widget.Clickable
|
||||||
showTemplates widget.Clickable
|
createGroup widget.Clickable
|
||||||
showRecycle widget.Clickable
|
renameGroup widget.Clickable
|
||||||
showLocalLifecycle widget.Clickable
|
deleteGroup widget.Clickable
|
||||||
showRemoteLifecycle widget.Clickable
|
togglePasswordInline widget.Clickable
|
||||||
masterKeyPasswordOnly widget.Clickable
|
showEntries widget.Clickable
|
||||||
masterKeyKeyFileOnly widget.Clickable
|
showTemplates widget.Clickable
|
||||||
masterKeyComposite widget.Clickable
|
showRecycle widget.Clickable
|
||||||
entryClicks []widget.Clickable
|
showLocalLifecycle widget.Clickable
|
||||||
historyClicks []widget.Clickable
|
showRemoteLifecycle widget.Clickable
|
||||||
attachmentClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
breadcrumbs []widget.Clickable
|
historyClicks []widget.Clickable
|
||||||
groupClicks []widget.Clickable
|
attachmentClicks []widget.Clickable
|
||||||
state appstate.State
|
breadcrumbs []widget.Clickable
|
||||||
masterKeyMode vault.MasterKeyMode
|
groupClicks []widget.Clickable
|
||||||
visible []entry
|
state appstate.State
|
||||||
currentPath []string
|
visible []entry
|
||||||
syncedPath []string
|
currentPath []string
|
||||||
selectedHistoryIndex int
|
syncedPath []string
|
||||||
showPassword bool
|
selectedHistoryIndex int
|
||||||
togglePassword widget.Clickable
|
showPassword bool
|
||||||
phoneSplit widget.Float
|
togglePassword widget.Clickable
|
||||||
splitDrag gesture.Drag
|
phoneSplit widget.Float
|
||||||
splitBase float32
|
splitDrag gesture.Drag
|
||||||
splitStartY float32
|
splitBase float32
|
||||||
phoneSpan int
|
splitStartY float32
|
||||||
eyeIcon *widget.Icon
|
phoneSpan int
|
||||||
eyeOffIcon *widget.Icon
|
eyeIcon *widget.Icon
|
||||||
copyIcon *widget.Icon
|
eyeOffIcon *widget.Icon
|
||||||
clipboardWriter clipboard.Writer
|
copyIcon *widget.Icon
|
||||||
loadingMessage string
|
clipboardWriter clipboard.Writer
|
||||||
lifecycleMode string
|
loadingMessage string
|
||||||
keyboardFocus focusID
|
lifecycleMode string
|
||||||
|
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,26 +389,11 @@ 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 == "" {
|
if path == "" {
|
||||||
return vault.MasterKey{}, fmt.Errorf("key file is required")
|
return vault.MasterKey{Password: password}, nil
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
@@ -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(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),
|
layout.Rigid(u.feedbackBanner),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -843,20 +916,45 @@ 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(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),
|
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 {
|
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||||
panel := card
|
panel := card
|
||||||
spacing := unit.Dp(12)
|
spacing := unit.Dp(12)
|
||||||
@@ -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),
|
||||||
lbl := material.Label(u.theme, unit.Sp(18), title)
|
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
|
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
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-98
@@ -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,13 +103,20 @@ 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(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")
|
if len(u.currentPath) == 0 {
|
||||||
}),
|
return layout.Dimensions{}
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
}
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete 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")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -161,8 +125,6 @@ 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 {
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
case appstate.SectionTemplates:
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password")
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
}),
|
||||||
|
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")
|
return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template")
|
||||||
}),
|
}
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry")
|
||||||
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(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate")
|
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)
|
||||||
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")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user