From adf91445c3a2857c97f76116f60cce32e368d61e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 16:02:19 -0700 Subject: [PATCH] Save remote bindings from the sync menu --- main.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/main.go b/main.go index 6973a7a..1acac2f 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" @@ -276,6 +278,7 @@ type ui struct { toggleMainMenu widget.Clickable openAdvancedSync widget.Clickable openSelectedVaultRemote widget.Clickable + saveCurrentRemoteBinding widget.Clickable openSecuritySettings widget.Clickable openRemotePrefsHelp widget.Clickable closeAdvancedSync widget.Clickable @@ -1985,6 +1988,66 @@ func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { }, true } +func remoteBindingSuffix(baseURL, path, username string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username))) + return hex.EncodeToString(sum[:8]) +} + +func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.remotePath.Text()) + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + localVaultPath := strings.TrimSpace(u.vaultPath.Text()) + + switch { + case localVaultPath == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("local vault path is required") + case baseURL == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote base URL is required") + case remotePath == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote path is required") + case username == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote username is required") + case password == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote password is required") + } + + suffix := remoteBindingSuffix(baseURL, remotePath, username) + credentialTitle := "WebDAV Sign-In" + if username != "" { + credentialTitle += " ยท " + username + } + + return appstate.RemoteBindingInput{ + LocalVaultPath: localVaultPath, + RemoteProfileID: "remote-profile-" + suffix, + RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), + BaseURL: baseURL, + RemotePath: remotePath, + CredentialEntryID: "remote-credential-" + suffix, + CredentialTitle: credentialTitle, + Username: username, + Password: password, + CredentialPath: append([]string(nil), u.currentPath...), + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }, nil +} + +func (u *ui) saveCurrentRemoteBindingAction() error { + input, err := u.currentRemoteBindingInput() + if err != nil { + return err + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + return nil +} + func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { binding, ok := u.selectedVaultRemoteBinding() if !ok { @@ -3341,6 +3404,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } u.startOpenRemoteAction() } + for u.saveCurrentRemoteBinding.Clicked(gtx) { + u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) + } for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -4531,6 +4597,26 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { } } } + if u.hasOpenVault() { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.remotePath.Text()) + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + if baseURL != "" && remotePath != "" && username != "" && password != "" { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Store the current remote sign-in in this vault") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, "Save Current Remote In Vault") + }), + ) + } + } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) }) } diff --git a/main_test.go b/main_test.go index f7a88db..1bdf8f3 100644 --- a/main_test.go +++ b/main_test.go @@ -4743,6 +4743,67 @@ func TestUISelectVaultRemoteCredentialEntryUpdatesSelection(t *testing.T) { } } +func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.currentPath = []string{"Crew", "Internet"} + u.vaultPath.SetText("/tmp/family.kdbx") + u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.remotePath.SetText("files/family/keepass.kdbx") + u.remoteUsername.SetText("tjulian") + u.remotePassword.SetText("token-1") + + if err := u.saveCurrentRemoteBindingAction(); err != nil { + t.Fatalf("saveCurrentRemoteBindingAction() error = %v", err) + } + + profiles := u.availableRemoteProfiles() + if len(profiles) != 1 { + t.Fatalf("len(availableRemoteProfiles()) = %d, want 1", len(profiles)) + } + if profiles[0].BaseURL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("saved profile = %#v, want persisted base URL", profiles[0]) + } + + entries := u.availableRemoteCredentialEntries() + var found bool + for _, entry := range entries { + if entry.Username == "tjulian" && entry.Password == "token-1" { + found = true + if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) { + t.Fatalf("credential path = %v, want [Crew Internet]", entry.Path) + } + } + } + if !found { + t.Fatalf("availableRemoteCredentialEntries() = %#v, want persisted tjulian/token-1 entry", entries) + } + + if got := u.selectedVaultRemoteProfileID; got == "" { + t.Fatal("selectedVaultRemoteProfileID = empty, want selected saved profile") + } + if got := u.selectedVaultRemoteCredentialEntryID; got == "" { + t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want selected saved credential entry") + } + if !u.state.Dirty { + t.Fatal("state.Dirty = false, want true after saving binding into vault") + } +} + +func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.vaultPath.SetText("/tmp/family.kdbx") + u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.remotePath.SetText("files/family/keepass.kdbx") + + if err := u.saveCurrentRemoteBindingAction(); err == nil { + t.Fatal("saveCurrentRemoteBindingAction() error = nil, want validation error") + } +} + func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { t.Parallel()