Simplify recent vault open flow and Android local sync
This commit is contained in:
@@ -61,6 +61,7 @@ type SynchronizableSession interface {
|
|||||||
type AdvancedSynchronizableSession interface {
|
type AdvancedSynchronizableSession interface {
|
||||||
CurrentSession
|
CurrentSession
|
||||||
SynchronizeFromLocal(string) error
|
SynchronizeFromLocal(string) error
|
||||||
|
SynchronizeFromLocalBytes(string, []byte) error
|
||||||
SynchronizeToLocal(string) error
|
SynchronizeToLocal(string) error
|
||||||
SynchronizeFromRemote(webdav.Client, string) error
|
SynchronizeFromRemote(webdav.Client, string) error
|
||||||
SynchronizeToRemote(webdav.Client, string) error
|
SynchronizeToRemote(webdav.Client, string) error
|
||||||
@@ -722,6 +723,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
|
||||||
|
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not advanced-synchronizable")
|
||||||
|
}
|
||||||
|
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) SynchronizeToLocal(path string) error {
|
func (s *State) SynchronizeToLocal(path string) error {
|
||||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1584,6 +1584,26 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
|
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
s.changedKey = key
|
s.changedKey = key
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
|||||||
- list navigation
|
- list navigation
|
||||||
- search focus
|
- search focus
|
||||||
- new-entry focus transitions
|
- new-entry focus transitions
|
||||||
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/rustyryan/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go).
|
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
|
||||||
|
|
||||||
## Current screen-reader boundary
|
## Current screen-reader boundary
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ go 1.26
|
|||||||
replace gioui.org/cmd => ./third_party/gioui-cmd
|
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.8.0
|
gioui.org v0.9.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
||||||
@@ -18,6 +18,8 @@ require (
|
|||||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||||
gioui.org/cmd v0.8.0 // indirect
|
gioui.org/cmd v0.8.0 // indirect
|
||||||
gioui.org/shader v1.0.8 // indirect
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
gioui.org/x v0.9.0 // indirect
|
||||||
|
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
|
||||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||||
github.com/Antonboom/errname v1.0.0 // indirect
|
github.com/Antonboom/errname v1.0.0 // indirect
|
||||||
@@ -74,6 +76,7 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||||
@@ -192,7 +195,7 @@ require (
|
|||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
go.uber.org/zap v1.24.0 // indirect
|
go.uber.org/zap v1.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||||
golang.org/x/image v0.37.0 // indirect
|
golang.org/x/image v0.37.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
|
|||||||
@@ -39,9 +39,15 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
|
|||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||||
|
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||||
|
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
gioui.org/x v0.9.0 h1:JUAP3okDXTEmN5WiDpaHbitVWajXKCXyyI5H8qt7KOQ=
|
||||||
|
gioui.org/x v0.9.0/go.mod h1:IWhEs8zCwiAUM1sfrdacHvcdUagoaKqcodF/N2D3pss=
|
||||||
|
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
|
||||||
|
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
|
||||||
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
||||||
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||||
@@ -224,6 +230,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
|||||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
@@ -671,6 +679,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
|
|||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
|
||||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
"gioui.org/x/explorer"
|
||||||
"git.julianfamily.org/keepassgo/api"
|
"git.julianfamily.org/keepassgo/api"
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apiaudit"
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
@@ -200,6 +202,7 @@ const (
|
|||||||
type ui struct {
|
type ui struct {
|
||||||
mode string
|
mode string
|
||||||
theme *material.Theme
|
theme *material.Theme
|
||||||
|
fileExplorer *explorer.Explorer
|
||||||
logoHorizontal paint.ImageOp
|
logoHorizontal paint.ImageOp
|
||||||
splashSquare paint.ImageOp
|
splashSquare paint.ImageOp
|
||||||
search widget.Editor
|
search widget.Editor
|
||||||
@@ -409,6 +412,8 @@ type ui struct {
|
|||||||
lifecycleMode string
|
lifecycleMode string
|
||||||
syncSourceMode syncSourceMode
|
syncSourceMode syncSourceMode
|
||||||
syncDirection syncDirection
|
syncDirection syncDirection
|
||||||
|
syncLocalImportName string
|
||||||
|
syncLocalImportContent []byte
|
||||||
syncLocalPath widget.Editor
|
syncLocalPath widget.Editor
|
||||||
syncRemoteBaseURL widget.Editor
|
syncRemoteBaseURL widget.Editor
|
||||||
syncRemotePath widget.Editor
|
syncRemotePath widget.Editor
|
||||||
@@ -1216,6 +1221,19 @@ func (u *ui) openAdvancedSyncDialog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearSyncLocalImport() {
|
||||||
|
u.syncLocalImportName = ""
|
||||||
|
u.syncLocalImportContent = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
||||||
|
name := strings.TrimSpace(u.syncLocalImportName)
|
||||||
|
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||||
switch mode {
|
switch mode {
|
||||||
case syncSourceRemote:
|
case syncSourceRemote:
|
||||||
@@ -1255,6 +1273,12 @@ func (u *ui) advancedSyncFromAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
||||||
|
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return errors.New(errVaultPathRequired)
|
return errors.New(errVaultPathRequired)
|
||||||
@@ -1269,6 +1293,37 @@ func (u *ui) advancedSyncFromAction() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) startChooseSyncLocalSourceAction() {
|
||||||
|
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||||
|
u.runAction("choose sync path", func() error {
|
||||||
|
u.clearSyncLocalImport()
|
||||||
|
return u.chooseExistingFileAction(&u.syncLocalPath)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
label := "Selected Android vault"
|
||||||
|
return func() error {
|
||||||
|
u.syncLocalImportName = label
|
||||||
|
u.syncLocalImportContent = append([]byte(nil), content...)
|
||||||
|
u.syncLocalPath.SetText(label)
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) advancedSyncToAction() error {
|
func (u *ui) advancedSyncToAction() error {
|
||||||
switch u.syncSourceMode {
|
switch u.syncSourceMode {
|
||||||
case syncSourceRemote:
|
case syncSourceRemote:
|
||||||
@@ -1713,6 +1768,14 @@ func (u *ui) latestRecentVault() (string, time.Time) {
|
|||||||
return "", time.Time{}
|
return "", time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) hasSelectedVaultPath() bool {
|
||||||
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) showLocalVaultChooser() bool {
|
||||||
|
return u.lifecycleMode != "local" || !u.hasSelectedVaultPath()
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
|
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
|
||||||
for _, record := range u.recentRemotes {
|
for _, record := range u.recentRemotes {
|
||||||
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
|
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
|
||||||
@@ -3060,7 +3123,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
|
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
|
||||||
}
|
}
|
||||||
for u.pickSyncLocalPath.Clicked(gtx) {
|
for u.pickSyncLocalPath.Clicked(gtx) {
|
||||||
u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) })
|
u.startChooseSyncLocalSourceAction()
|
||||||
}
|
}
|
||||||
for i := range u.recentVaultClicks {
|
for i := range u.recentVaultClicks {
|
||||||
for u.recentVaultClicks[i].Clicked(gtx) {
|
for u.recentVaultClicks[i].Clicked(gtx) {
|
||||||
@@ -5863,6 +5926,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
var ops op.Ops
|
var ops op.Ops
|
||||||
manager := &session.Manager{}
|
manager := &session.Manager{}
|
||||||
ui := newUIWithSession(mode, manager, paths)
|
ui := newUIWithSession(mode, manager, paths)
|
||||||
|
ui.fileExplorer = explorer.NewExplorer(w)
|
||||||
ui.invalidate = w.Invalidate
|
ui.invalidate = w.Invalidate
|
||||||
ui.clipboardWriter = newPlatformClipboardWriter(runtime.GOOS, w.Invalidate)
|
ui.clipboardWriter = newPlatformClipboardWriter(runtime.GOOS, w.Invalidate)
|
||||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||||
@@ -5877,6 +5941,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
e := w.Event()
|
e := w.Event()
|
||||||
|
ui.fileExplorer.ListenEvents(e)
|
||||||
switch e := e.(type) {
|
switch e := e.(type) {
|
||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
return e.Err
|
return e.Err
|
||||||
|
|||||||
+103
-1
@@ -135,7 +135,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T)
|
|||||||
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
|
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
|
||||||
},
|
},
|
||||||
RecycleBin: []vault.Entry{
|
RecycleBin: []vault.Entry{
|
||||||
{ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}},
|
{ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1754,6 +1754,67 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||||
|
|
||||||
|
writeKDBXMainTestFile(t, currentPath, vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-current",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-current",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}},
|
||||||
|
}, key)
|
||||||
|
|
||||||
|
var other bytes.Buffer
|
||||||
|
if err := vault.SaveKDBX(&other, vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-other",
|
||||||
|
Title: "Bellagio",
|
||||||
|
Username: "rustyryan",
|
||||||
|
Password: "token-other",
|
||||||
|
URL: "https://bellagio.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}},
|
||||||
|
}, key.Password); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(other) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
u.masterPassword.SetText(key.Password)
|
||||||
|
u.vaultPath.SetText(currentPath)
|
||||||
|
if err := u.openVaultAction(); err != nil {
|
||||||
|
t.Fatalf("openVaultAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.openAdvancedSyncDialog()
|
||||||
|
u.syncDirection = syncDirectionPull
|
||||||
|
u.syncSourceMode = syncSourceLocal
|
||||||
|
u.syncLocalImportName = "Selected Android vault"
|
||||||
|
u.syncLocalImportContent = other.Bytes()
|
||||||
|
u.syncLocalPath.SetText("Selected Android vault")
|
||||||
|
if err := u.advancedSyncAction(); err != nil {
|
||||||
|
t.Fatalf("advancedSyncAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened session.Manager
|
||||||
|
if err := reopened.Open(currentPath, key); err != nil {
|
||||||
|
t.Fatalf("reopen Open(current) error = %v", err)
|
||||||
|
}
|
||||||
|
model, err := reopened.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIStartOpenVaultActionAppliesResultOnMainThread(t *testing.T) {
|
func TestUIStartOpenVaultActionAppliesResultOnMainThread(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -4275,6 +4336,47 @@ func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestoreStartupLifecycleTargetSelectsMostRecentLocalVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
u.lifecycleMode = "remote"
|
||||||
|
u.vaultPath.SetText("")
|
||||||
|
u.recentVaults = []string{"/tmp/example.kdbx"}
|
||||||
|
u.recentVaultUsedAt["/tmp/example.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC)
|
||||||
|
u.recentRemotes = nil
|
||||||
|
|
||||||
|
u.restoreStartupLifecycleTarget()
|
||||||
|
|
||||||
|
if got := u.lifecycleMode; got != "local" {
|
||||||
|
t.Fatalf("lifecycleMode after restore = %q, want local", got)
|
||||||
|
}
|
||||||
|
if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" {
|
||||||
|
t.Fatalf("vaultPath after restore = %q, want /tmp/example.kdbx", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowLocalVaultChooser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
u.lifecycleMode = "local"
|
||||||
|
u.vaultPath.SetText("")
|
||||||
|
if got := u.showLocalVaultChooser(); !got {
|
||||||
|
t.Fatal("showLocalVaultChooser() = false, want true when no local vault is selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.vaultPath.SetText("/tmp/example.kdbx")
|
||||||
|
if got := u.showLocalVaultChooser(); got {
|
||||||
|
t.Fatal("showLocalVaultChooser() = true, want false when a local vault is selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.lifecycleMode = "remote"
|
||||||
|
if got := u.showLocalVaultChooser(); !got {
|
||||||
|
t.Fatal("showLocalVaultChooser() = false, want true outside local lifecycle mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) {
|
func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -177,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
|
|||||||
return m.persistMergedToCurrentSource(merged)
|
return m.persistMergedToCurrentSource(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
|
||||||
|
other, _, err := loadLocalSourceBytes(name, content, m.key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
merged, err := m.mergedWithPeer(other)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.persistMergedToCurrentSource(merged)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) SynchronizeToLocal(path string) error {
|
func (m *Manager) SynchronizeToLocal(path string) error {
|
||||||
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -908,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
|
|||||||
return model, config, nil
|
return model, config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||||
|
if len(content) == 0 {
|
||||||
|
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
|
||||||
|
}
|
||||||
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||||
|
if err != nil {
|
||||||
|
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
|
||||||
|
}
|
||||||
|
return model, config, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||||
model, config, err := loadLocalSource(path, key)
|
model, config, err := loadLocalSource(path, key)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -953,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||||
|
|
||||||
|
currentModel := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-current",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-current",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
otherModel := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-other",
|
||||||
|
Title: "Bellagio",
|
||||||
|
Username: "rustyryan",
|
||||||
|
Password: "token-other",
|
||||||
|
URL: "https://bellagio.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
writeKDBXTestFile(t, currentPath, currentModel, key)
|
||||||
|
var other bytes.Buffer
|
||||||
|
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(other) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess Manager
|
||||||
|
if err := sess.Open(currentPath, key); err != nil {
|
||||||
|
t.Fatalf("Open(current) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
|
||||||
|
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened Manager
|
||||||
|
if err := reopened.Open(currentPath, key); err != nil {
|
||||||
|
t.Fatalf("reopen Open(current) error = %v", err)
|
||||||
|
}
|
||||||
|
current, err := reopened.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||||
busy := u.lifecycleBusy()
|
busy := u.lifecycleBusy()
|
||||||
|
showLocalChooser := u.showLocalVaultChooser()
|
||||||
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 {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
|
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
|
||||||
@@ -161,24 +162,45 @@ 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(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
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 {
|
||||||
if busy {
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
return u.recentVaultList(gtx)
|
return u.recentVaultList(gtx)
|
||||||
}),
|
}),
|
||||||
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 !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
selectedPath := strings.TrimSpace(u.vaultPath.Text())
|
selectedPath := strings.TrimSpace(u.vaultPath.Text())
|
||||||
switch {
|
switch {
|
||||||
@@ -221,7 +243,7 @@ func (u *ui) lifecycleControls(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(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...")
|
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user