Add advanced synchronize dialog and APK tooling
This commit is contained in:
+2
-1
@@ -1 +1,2 @@
|
||||
gio-keepass-mock
|
||||
build/
|
||||
*.apk
|
||||
|
||||
@@ -125,6 +125,7 @@ These features are product requirements, not “nice to have” ideas.
|
||||
|
||||
- Keep `golangci-lint` passing.
|
||||
- Keep `go test ./...` passing.
|
||||
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
|
||||
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
||||
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
||||
- Do not commit generated binaries.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Android Build
|
||||
|
||||
Build the APK with:
|
||||
|
||||
```sh
|
||||
make apk
|
||||
```
|
||||
|
||||
Environment:
|
||||
|
||||
- `ANDROID_SDK_ROOT` or `ANDROID_HOME` must point at an Android SDK install.
|
||||
- `APP_ID` overrides the Android application id.
|
||||
- `APK_OUT` overrides the output path.
|
||||
- `APK_VERSION` overrides the packaged app version.
|
||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
|
||||
|
||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||
|
||||
```sh
|
||||
go tool gogio -target android ...
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
|
||||
.PHONY: apk
|
||||
apk:
|
||||
@test -n "$${ANDROID_SDK_ROOT:-$${ANDROID_HOME:-}}" || { echo "Set ANDROID_SDK_ROOT or ANDROID_HOME"; exit 1; }
|
||||
go tool gogio -target android \
|
||||
-buildmode exe \
|
||||
-appid $(APP_ID) \
|
||||
-o $(APK_OUT) \
|
||||
-version $(APK_VERSION) \
|
||||
-minsdk $(ANDROID_MIN_SDK) \
|
||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||
.
|
||||
@@ -53,6 +53,14 @@ type SynchronizableSession interface {
|
||||
Synchronize() error
|
||||
}
|
||||
|
||||
type AdvancedSynchronizableSession interface {
|
||||
CurrentSession
|
||||
SynchronizeFromLocal(string) error
|
||||
SynchronizeToLocal(string) error
|
||||
SynchronizeFromRemote(webdav.Client, string) error
|
||||
SynchronizeToRemote(webdav.Client, string) error
|
||||
}
|
||||
|
||||
type CreateableSession interface {
|
||||
CurrentSession
|
||||
Create(vault.Model, vault.MasterKey) error
|
||||
@@ -529,6 +537,54 @@ func (s *State) Synchronize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SynchronizeFromLocal(path string) error {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not advanced-synchronizable")
|
||||
}
|
||||
if err := session.SynchronizeFromLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SynchronizeToLocal(path string) error {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not advanced-synchronizable")
|
||||
}
|
||||
if err := session.SynchronizeToLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SynchronizeFromRemote(client webdav.Client, path string) error {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not advanced-synchronizable")
|
||||
}
|
||||
if err := session.SynchronizeFromRemote(client, path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SynchronizeToRemote(client webdav.Client, path string) error {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not advanced-synchronizable")
|
||||
}
|
||||
if err := session.SynchronizeToRemote(client, path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateVault(key vault.MasterKey) error {
|
||||
session, ok := s.Session.(CreateableSession)
|
||||
if !ok {
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
gioui.org/cmd v0.9.0 // indirect
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||
@@ -26,6 +27,7 @@ require (
|
||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/akavel/rsrc v0.10.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.0 // indirect
|
||||
@@ -207,4 +209,7 @@ require (
|
||||
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
|
||||
)
|
||||
|
||||
tool github.com/golangci/golangci-lint/cmd/golangci-lint
|
||||
tool (
|
||||
gioui.org/cmd/gogio
|
||||
github.com/golangci/golangci-lint/cmd/golangci-lint
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||
gioui.org/cmd v0.9.0 h1:H1F2u3vBAd8TDRvaJd4IbrbbiOPBWc7Z3ZykOYoq/20=
|
||||
gioui.org/cmd v0.9.0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
|
||||
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/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
@@ -66,6 +68,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
|
||||
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
|
||||
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||
|
||||
@@ -88,10 +88,10 @@ type recentVaultRecord struct {
|
||||
}
|
||||
|
||||
type recentRemoteRecord struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
LastGroup []string `json:"lastGroup,omitempty"`
|
||||
}
|
||||
|
||||
@@ -99,6 +99,20 @@ type uiPreferences struct {
|
||||
GroupControlsHidden bool `json:"groupControlsHidden"`
|
||||
}
|
||||
|
||||
type syncSourceMode string
|
||||
|
||||
const (
|
||||
syncSourceLocal syncSourceMode = "local"
|
||||
syncSourceRemote syncSourceMode = "remote"
|
||||
)
|
||||
|
||||
type syncDirection string
|
||||
|
||||
const (
|
||||
syncDirectionPull syncDirection = "pull"
|
||||
syncDirectionPush syncDirection = "push"
|
||||
)
|
||||
|
||||
type ui struct {
|
||||
mode string
|
||||
theme *material.Theme
|
||||
@@ -143,10 +157,15 @@ type ui struct {
|
||||
openRemote widget.Clickable
|
||||
changeMasterKey widget.Clickable
|
||||
synchronizeVault widget.Clickable
|
||||
toggleSyncMenu widget.Clickable
|
||||
openAdvancedSync widget.Clickable
|
||||
closeAdvancedSync widget.Clickable
|
||||
runAdvancedSync widget.Clickable
|
||||
editEntry widget.Clickable
|
||||
cancelEdit widget.Clickable
|
||||
pickVaultPath widget.Clickable
|
||||
pickKeyFile widget.Clickable
|
||||
pickSyncLocalPath widget.Clickable
|
||||
addEntry widget.Clickable
|
||||
saveEntry widget.Clickable
|
||||
duplicateEntry widget.Clickable
|
||||
@@ -169,11 +188,16 @@ type ui struct {
|
||||
addCustomField widget.Clickable
|
||||
toggleGroupControls widget.Clickable
|
||||
togglePasswordInline widget.Clickable
|
||||
toggleSyncPassword widget.Clickable
|
||||
showEntries widget.Clickable
|
||||
showTemplates widget.Clickable
|
||||
showRecycle widget.Clickable
|
||||
showLocalLifecycle widget.Clickable
|
||||
showRemoteLifecycle widget.Clickable
|
||||
showSyncLocal widget.Clickable
|
||||
showSyncRemote widget.Clickable
|
||||
showSyncPull widget.Clickable
|
||||
showSyncPush widget.Clickable
|
||||
rememberRemoteAuth widget.Bool
|
||||
entryClicks []widget.Clickable
|
||||
historyClicks []widget.Clickable
|
||||
@@ -200,9 +224,20 @@ type ui struct {
|
||||
copyIcon *widget.Icon
|
||||
expandMoreIcon *widget.Icon
|
||||
expandLessIcon *widget.Icon
|
||||
chevronDownIcon *widget.Icon
|
||||
clipboardWriter clipboard.Writer
|
||||
loadingMessage string
|
||||
lifecycleMode string
|
||||
syncSourceMode syncSourceMode
|
||||
syncDirection syncDirection
|
||||
syncLocalPath widget.Editor
|
||||
syncRemoteBaseURL widget.Editor
|
||||
syncRemotePath widget.Editor
|
||||
syncRemoteUsername widget.Editor
|
||||
syncRemotePassword widget.Editor
|
||||
syncDialogOpen bool
|
||||
syncMenuOpen bool
|
||||
showSyncPassword bool
|
||||
keyboardFocus focusID
|
||||
defaultSaveAsPath string
|
||||
recentVaultsPath string
|
||||
@@ -268,6 +303,11 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
remotePath: widget.Editor{SingleLine: true, Submit: false},
|
||||
remoteUsername: widget.Editor{SingleLine: true, Submit: false},
|
||||
remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'},
|
||||
syncLocalPath: widget.Editor{SingleLine: true, Submit: false},
|
||||
syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false},
|
||||
syncRemotePath: widget.Editor{SingleLine: true, Submit: false},
|
||||
syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false},
|
||||
syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'},
|
||||
masterPassword: widget.Editor{SingleLine: true, Submit: false},
|
||||
keyFilePath: widget.Editor{SingleLine: true, Submit: false},
|
||||
entryID: widget.Editor{SingleLine: true, Submit: false},
|
||||
@@ -303,6 +343,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
recentRemotesPath: paths.RecentRemotesPath,
|
||||
recentVaultGroups: map[string][]string{},
|
||||
now: time.Now,
|
||||
syncSourceMode: syncSourceLocal,
|
||||
syncDirection: syncDirectionPull,
|
||||
}
|
||||
u.state.Session = sess
|
||||
u.phoneSplit.Value = 0.46
|
||||
@@ -311,6 +353,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
||||
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
|
||||
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
|
||||
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
|
||||
u.passwordProfile.SetText("strong")
|
||||
u.keyboardFocus = focusSearch
|
||||
u.setCustomFieldRows(nil)
|
||||
@@ -648,6 +691,77 @@ func (u *ui) synchronizeAction() error {
|
||||
if err := u.state.Synchronize(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.syncMenuOpen = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openAdvancedSyncDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncAction() error {
|
||||
switch u.syncDirection {
|
||||
case syncDirectionPush:
|
||||
return u.advancedSyncToAction()
|
||||
default:
|
||||
return u.advancedSyncFromAction()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncFromAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncToAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeToRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeToLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -1320,6 +1434,19 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
for u.synchronizeVault.Clicked(gtx) {
|
||||
u.runAction("synchronize vault", u.synchronizeAction)
|
||||
}
|
||||
for u.toggleSyncMenu.Clicked(gtx) {
|
||||
u.syncMenuOpen = !u.syncMenuOpen
|
||||
}
|
||||
for u.openAdvancedSync.Clicked(gtx) {
|
||||
u.openAdvancedSyncDialog()
|
||||
}
|
||||
for u.closeAdvancedSync.Clicked(gtx) {
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
}
|
||||
for u.runAdvancedSync.Clicked(gtx) {
|
||||
u.runAction("advanced synchronize vault", u.advancedSyncAction)
|
||||
}
|
||||
for u.unlockVault.Clicked(gtx) {
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
}
|
||||
@@ -1341,6 +1468,18 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
for u.showRemoteLifecycle.Clicked(gtx) {
|
||||
u.lifecycleMode = "remote"
|
||||
}
|
||||
for u.showSyncLocal.Clicked(gtx) {
|
||||
u.syncSourceMode = syncSourceLocal
|
||||
}
|
||||
for u.showSyncRemote.Clicked(gtx) {
|
||||
u.syncSourceMode = syncSourceRemote
|
||||
}
|
||||
for u.showSyncPull.Clicked(gtx) {
|
||||
u.syncDirection = syncDirectionPull
|
||||
}
|
||||
for u.showSyncPush.Clicked(gtx) {
|
||||
u.syncDirection = syncDirectionPush
|
||||
}
|
||||
for u.lockVault.Clicked(gtx) {
|
||||
u.runAction("lock vault", u.lockAction)
|
||||
}
|
||||
@@ -1358,6 +1497,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
for u.pickKeyFile.Clicked(gtx) {
|
||||
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
|
||||
}
|
||||
for u.pickSyncLocalPath.Clicked(gtx) {
|
||||
u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) })
|
||||
}
|
||||
for i := range u.recentVaultClicks {
|
||||
for u.recentVaultClicks[i].Clicked(gtx) {
|
||||
if i < len(u.recentVaults) {
|
||||
@@ -1462,66 +1604,84 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
for u.togglePasswordInline.Clicked(gtx) {
|
||||
u.showPassword = !u.showPassword
|
||||
}
|
||||
for u.toggleSyncPassword.Clicked(gtx) {
|
||||
u.showSyncPassword = !u.showSyncPassword
|
||||
if u.showSyncPassword {
|
||||
u.syncRemotePassword.Mask = 0
|
||||
} else {
|
||||
u.syncRemotePassword.Mask = '•'
|
||||
}
|
||||
}
|
||||
if _, changed := u.search.Update(gtx); changed {
|
||||
u.filter()
|
||||
}
|
||||
inset := layout.UniformInset(unit.Dp(16))
|
||||
return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(u.header),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.bannerSurface().Kind == bannerNone {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(u.banner),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if u.shouldShowLifecycleSetup() {
|
||||
return u.lifecycleScreen(gtx)
|
||||
}
|
||||
if u.shouldUseLockedSinglePane() {
|
||||
return u.detailPanel(gtx)
|
||||
}
|
||||
if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) {
|
||||
u.phoneSpan = gtx.Constraints.Max.Y
|
||||
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value)
|
||||
if listHeight < gtx.Dp(unit.Dp(180)) {
|
||||
listHeight = gtx.Dp(unit.Dp(180))
|
||||
}
|
||||
if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) {
|
||||
listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = listHeight
|
||||
gtx.Constraints.Max.Y = listHeight
|
||||
return u.listPanel(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(u.phoneSlider),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
func() layout.FlexChild {
|
||||
if u.shouldUseCompactPhoneDetailPane() {
|
||||
return layout.Rigid(u.detailPanel)
|
||||
layout.Rigid(u.header),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.bannerSurface().Kind == bannerNone {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(u.banner),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if u.shouldShowLifecycleSetup() {
|
||||
return u.lifecycleScreen(gtx)
|
||||
}
|
||||
if u.shouldUseLockedSinglePane() {
|
||||
return u.detailPanel(gtx)
|
||||
}
|
||||
if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) {
|
||||
u.phoneSpan = gtx.Constraints.Max.Y
|
||||
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value)
|
||||
if listHeight < gtx.Dp(unit.Dp(180)) {
|
||||
listHeight = gtx.Dp(unit.Dp(180))
|
||||
}
|
||||
return layout.Flexed(1, u.detailPanel)
|
||||
}(),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Flexed(0.38, u.listPanel),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(0.62, u.detailPanel),
|
||||
if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) {
|
||||
listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = listHeight
|
||||
gtx.Constraints.Max.Y = listHeight
|
||||
return u.listPanel(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(u.phoneSlider),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
func() layout.FlexChild {
|
||||
if u.shouldUseCompactPhoneDetailPane() {
|
||||
return layout.Rigid(u.detailPanel)
|
||||
}
|
||||
return layout.Flexed(1, u.detailPanel)
|
||||
}(),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Flexed(0.38, u.listPanel),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(0.62, u.detailPanel),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.syncDialogOpen {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.syncDialog(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||
@@ -1539,6 +1699,134 @@ func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(620))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
||||
}
|
||||
if width < 1 {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return card(gtx, u.syncDialogContent)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(20), "Synchronize...")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "Choose another source and whether to pull it into the current vault or push the current vault to it.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showSyncPull, "Pull From Source")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showSyncPush, "Push To Source")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showSyncLocal, "Local File")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.syncSourceMode == syncSourceRemote {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncPasswordField(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, "Synchronize")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
field := func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return outlinedFieldState(gtx, false, field)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -1578,10 +1866,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
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(u.syncButtonGroup),
|
||||
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")
|
||||
@@ -1590,6 +1875,45 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize")
|
||||
btn.CornerRadius = unit.Dp(10)
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncMenuToggle(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.syncMenuOpen {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.syncMenu)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Synchronize...")
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
panel := card
|
||||
spacing := unit.Dp(12)
|
||||
@@ -1848,10 +2172,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize")
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(u.syncButtonGroup),
|
||||
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")
|
||||
@@ -2288,12 +2609,6 @@ func detailLine(th *material.Theme, label, value string) layout.Widget {
|
||||
|
||||
func (u *ui) passwordLine(label, value string) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
icon := u.eyeIcon
|
||||
desc := "Show password"
|
||||
if u.showPassword {
|
||||
icon = u.eyeOffIcon
|
||||
desc = "Hide password"
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label))
|
||||
@@ -2307,12 +2622,7 @@ func (u *ui) passwordLine(label, value string) layout.Widget {
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.IconButton(u.theme, &u.togglePasswordInline, icon, desc)
|
||||
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
|
||||
btn.Color = accentColor
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
return u.inlinePasswordToggle(gtx, &u.togglePasswordInline, u.showPassword)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -2320,6 +2630,21 @@ func (u *ui) passwordLine(label, value string) layout.Widget {
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, showing bool) layout.Dimensions {
|
||||
icon := u.eyeIcon
|
||||
desc := "Show password"
|
||||
if showing {
|
||||
icon = u.eyeOffIcon
|
||||
desc = "Hide password"
|
||||
}
|
||||
btn := material.IconButton(u.theme, click, icon, desc)
|
||||
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
|
||||
btn.Color = accentColor
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) detailPasswordValue() string {
|
||||
item, ok := u.selectedEntry()
|
||||
if !ok {
|
||||
|
||||
+153
-1
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -12,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
|
||||
@@ -678,6 +679,145 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||
otherPath := filepath.Join(t.TempDir(), "other.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)
|
||||
writeKDBXMainTestFile(t, otherPath, 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)
|
||||
|
||||
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.syncLocalPath.SetText(otherPath)
|
||||
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 TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(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 remoteBytes bytes.Buffer
|
||||
if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "entry-remote",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-remote",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}},
|
||||
}, key); err != nil {
|
||||
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
|
||||
}
|
||||
|
||||
etag := "\"v1\""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("ETag", etag)
|
||||
_, _ = w.Write(remoteBytes.Bytes())
|
||||
case http.MethodPut:
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(PUT body) error = %v", err)
|
||||
}
|
||||
remoteBytes.Reset()
|
||||
if _, err := remoteBytes.Write(payload); err != nil {
|
||||
t.Fatalf("Write(remoteBytes) error = %v", err)
|
||||
}
|
||||
etag = "\"v2\""
|
||||
w.Header().Set("ETag", etag)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
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 = syncDirectionPush
|
||||
u.syncSourceMode = syncSourceRemote
|
||||
u.syncRemoteBaseURL.SetText(server.URL)
|
||||
u.syncRemotePath.SetText("vaults/other.kdbx")
|
||||
if err := u.advancedSyncAction(); err != nil {
|
||||
t.Fatalf("advancedSyncAction() error = %v", err)
|
||||
}
|
||||
|
||||
var reopened session.Manager
|
||||
if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil {
|
||||
t.Fatalf("OpenRemote(reopened) 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 TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2839,3 +2979,15 @@ func TestUICurrentMasterKeyReportsUnreadableKeyFile(t *testing.T) {
|
||||
t.Fatalf("currentMasterKey() error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeKDBXMainTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) {
|
||||
t.Helper()
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
|
||||
t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,64 @@ func (m *Manager) Synchronize() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SynchronizeFromLocal(path string) error {
|
||||
other, _, err := loadLocalSource(path, 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 {
|
||||
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := m.mergedWithPeer(other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveModelToLocal(path, merged, m.key, configOrCurrent(config, m.config)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.model = merged
|
||||
m.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SynchronizeFromRemote(client webdav.Client, path string) error {
|
||||
other, _, _, err := loadRemoteSource(client, path, m.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := m.mergedWithPeer(other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.persistMergedToCurrentSource(merged)
|
||||
}
|
||||
|
||||
func (m *Manager) SynchronizeToRemote(client webdav.Client, path string) error {
|
||||
other, config, version, err := loadRemoteSourceOrEmpty(client, path, m.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := m.mergedWithPeer(other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveModelToRemote(client, path, merged, m.key, configOrCurrent(config, m.config), version); err != nil {
|
||||
return err
|
||||
}
|
||||
m.model = merged
|
||||
m.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SaveAs(path string) error {
|
||||
if err := m.saveToPath(path); err != nil {
|
||||
return err
|
||||
@@ -351,6 +409,61 @@ func (m *Manager) baseModel() (vault.Model, error) {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func (m *Manager) mergedWithPeer(other vault.Model) (vault.Model, error) {
|
||||
current, err := m.currentModelForPersistence()
|
||||
if err != nil {
|
||||
return vault.Model{}, err
|
||||
}
|
||||
return mergePeerModels(current, other), nil
|
||||
}
|
||||
|
||||
func (m *Manager) persistMergedToCurrentSource(merged vault.Model) error {
|
||||
switch {
|
||||
case m.remoteClient != nil && m.remotePath != "":
|
||||
if err := saveModelToRemote(*m.remoteClient, m.remotePath, merged, m.key, configOrCurrent(m.config, nil), m.remoteVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.reloadCurrentRemote(merged)
|
||||
case m.path != "":
|
||||
if err := saveModelToLocal(m.path, merged, m.key, configOrCurrent(m.config, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.reloadCurrentLocal(merged)
|
||||
default:
|
||||
return ErrNoPath
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
|
||||
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.model = merged
|
||||
m.encoded = encoded
|
||||
m.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
||||
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, version, err := m.remoteClient.Open(m.remotePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
|
||||
}
|
||||
m.model = merged
|
||||
m.encoded = encoded
|
||||
m.remoteVersion = version
|
||||
m.locked = false
|
||||
if len(content) > 0 {
|
||||
m.encoded = content
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeModels(base, local, latest vault.Model) vault.Model {
|
||||
merged := latest
|
||||
merged.Entries = mergeEntrySet(base.Entries, local.Entries, latest.Entries)
|
||||
@@ -360,6 +473,15 @@ func mergeModels(base, local, latest vault.Model) vault.Model {
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergePeerModels(primary, secondary vault.Model) vault.Model {
|
||||
merged := cloneModel(secondary)
|
||||
merged.Entries = mergePeerEntrySet(primary.Entries, secondary.Entries)
|
||||
merged.Templates = mergePeerEntrySet(primary.Templates, secondary.Templates)
|
||||
merged.RecycleBin = mergePeerEntrySet(primary.RecycleBin, secondary.RecycleBin)
|
||||
merged.Groups = mergePeerGroups(primary.Groups, secondary.Groups)
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeEntrySet(base, local, latest []vault.Entry) []vault.Entry {
|
||||
baseByID := mapEntries(base)
|
||||
localByID := mapEntries(local)
|
||||
@@ -416,6 +538,33 @@ func mergeConflictedEntry(current, latest vault.Entry) vault.Entry {
|
||||
return current
|
||||
}
|
||||
|
||||
func mergePeerEntrySet(primary, secondary []vault.Entry) []vault.Entry {
|
||||
outByID := mapEntries(secondary)
|
||||
for _, item := range primary {
|
||||
if existing, ok := outByID[item.ID]; ok && !sameEntryVersion(item, existing) {
|
||||
outByID[item.ID] = mergeConflictedEntry(cloneEntry(item), existing)
|
||||
continue
|
||||
}
|
||||
outByID[item.ID] = cloneEntry(item)
|
||||
}
|
||||
|
||||
out := make([]vault.Entry, 0, len(outByID))
|
||||
for _, item := range outByID {
|
||||
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 {
|
||||
@@ -482,6 +631,20 @@ func cloneHistory(history []vault.Entry) []vault.Entry {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneModel(model vault.Model) vault.Model {
|
||||
out := model
|
||||
out.Entries = cloneHistory(model.Entries)
|
||||
out.Templates = cloneHistory(model.Templates)
|
||||
out.RecycleBin = cloneHistory(model.RecycleBin)
|
||||
if len(model.Groups) > 0 {
|
||||
out.Groups = make([][]string, len(model.Groups))
|
||||
for i := range model.Groups {
|
||||
out.Groups[i] = slices.Clone(model.Groups[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameEntryVersion(a, b vault.Entry) bool {
|
||||
return entriesEqual(a, b)
|
||||
}
|
||||
@@ -526,6 +689,119 @@ func mergeGroups(base, local, latest [][]string) [][]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func mergePeerGroups(primary, secondary [][]string) [][]string {
|
||||
set := map[string][]string{}
|
||||
for _, path := range secondary {
|
||||
set[pathKey(path)] = slices.Clone(path)
|
||||
}
|
||||
for _, path := range primary {
|
||||
set[pathKey(path)] = slices.Clone(path)
|
||||
}
|
||||
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 loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", path, err)
|
||||
}
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", path, err)
|
||||
}
|
||||
return model, config, nil
|
||||
}
|
||||
|
||||
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||
model, config, err := loadLocalSource(path, key)
|
||||
if err == nil {
|
||||
return model, config, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), os.ErrNotExist.Error()) {
|
||||
return vault.Model{}, nil, nil
|
||||
}
|
||||
return vault.Model{}, nil, err
|
||||
}
|
||||
|
||||
func loadRemoteSource(client webdav.Client, path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, webdav.Version, error) {
|
||||
content, version, err := client.Open(path)
|
||||
if err != nil {
|
||||
return vault.Model{}, nil, webdav.Version{}, fmt.Errorf("open remote %s for synchronize: %w", path, err)
|
||||
}
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||
if err != nil {
|
||||
return vault.Model{}, nil, webdav.Version{}, fmt.Errorf("decode remote %s for synchronize: %w", path, err)
|
||||
}
|
||||
return model, config, version, nil
|
||||
}
|
||||
|
||||
func loadRemoteSourceOrEmpty(client webdav.Client, path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, webdav.Version, error) {
|
||||
model, config, version, err := loadRemoteSource(client, path, key)
|
||||
if err == nil {
|
||||
return model, config, version, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "unexpected status 404") {
|
||||
return vault.Model{}, nil, webdav.Version{}, nil
|
||||
}
|
||||
return vault.Model{}, nil, webdav.Version{}, err
|
||||
}
|
||||
|
||||
func encodeModelWithConfig(model vault.Model, key vault.MasterKey, config *vault.KDBXConfig) ([]byte, error) {
|
||||
var encoded bytes.Buffer
|
||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, config); err != nil {
|
||||
return nil, fmt.Errorf("encode synchronized vault: %w", err)
|
||||
}
|
||||
return encoded.Bytes(), nil
|
||||
}
|
||||
|
||||
func saveModelToLocal(path string, model vault.Model, key vault.MasterKey, config *vault.KDBXConfig) error {
|
||||
encoded, err := encodeModelWithConfig(model, key, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create parent dir for %s: %w", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
return fmt.Errorf("write synchronized %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveModelToRemote(client webdav.Client, path string, model vault.Model, key vault.MasterKey, config *vault.KDBXConfig, version webdav.Version) error {
|
||||
encoded, err := encodeModelWithConfig(model, key, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Save(path, bytes.NewReader(encoded), version); err != nil {
|
||||
return fmt.Errorf("save synchronized remote %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configOrCurrent(config, fallback *vault.KDBXConfig) *vault.KDBXConfig {
|
||||
if config != nil {
|
||||
return config
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func pathKey(path []string) string {
|
||||
return strings.Join(path, "\x00")
|
||||
}
|
||||
|
||||
@@ -790,3 +790,218 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
t.Fatalf("History[0] = %#v, want displaced remote version first", got[0].History[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||
otherPath := filepath.Join(t.TempDir(), "other.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)
|
||||
writeKDBXTestFile(t, otherPath, otherModel, key)
|
||||
|
||||
var sess Manager
|
||||
if err := sess.Open(currentPath, key); err != nil {
|
||||
t.Fatalf("Open(current) error = %v", err)
|
||||
}
|
||||
|
||||
if err := sess.SynchronizeFromLocal(otherPath); err != nil {
|
||||
t.Fatalf("SynchronizeFromLocal() 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) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||
otherPath := filepath.Join(t.TempDir(), "other.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)
|
||||
writeKDBXTestFile(t, otherPath, otherModel, key)
|
||||
|
||||
var sess Manager
|
||||
if err := sess.Open(currentPath, key); err != nil {
|
||||
t.Fatalf("Open(current) error = %v", err)
|
||||
}
|
||||
|
||||
if err := sess.SynchronizeToLocal(otherPath); err != nil {
|
||||
t.Fatalf("SynchronizeToLocal() error = %v", err)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
if err := reopened.Open(otherPath, key); err != nil {
|
||||
t.Fatalf("reopen Open(other) 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 TestSynchronizeToRemoteWritesMergedVaultToTarget(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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
remoteModel := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-remote",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-remote",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeKDBXTestFile(t, currentPath, currentModel, key)
|
||||
|
||||
var remoteBytes bytes.Buffer
|
||||
if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil {
|
||||
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
|
||||
}
|
||||
|
||||
etag := "\"v1\""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("ETag", etag)
|
||||
_, _ = w.Write(remoteBytes.Bytes())
|
||||
case http.MethodPut:
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(PUT body) error = %v", err)
|
||||
}
|
||||
remoteBytes.Reset()
|
||||
if _, err := remoteBytes.Write(payload); err != nil {
|
||||
t.Fatalf("Write(remoteBytes) error = %v", err)
|
||||
}
|
||||
etag = "\"v2\""
|
||||
w.Header().Set("ETag", etag)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var sess Manager
|
||||
if err := sess.Open(currentPath, key); err != nil {
|
||||
t.Fatalf("Open(current) error = %v", err)
|
||||
}
|
||||
|
||||
if err := sess.SynchronizeToRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx"); err != nil {
|
||||
t.Fatalf("SynchronizeToRemote() error = %v", err)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil {
|
||||
t.Fatalf("OpenRemote(reopened) 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 writeKDBXTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) {
|
||||
t.Helper()
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
|
||||
t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user