Implement local-first remote sync policy
This commit is contained in:
@@ -159,6 +159,7 @@ type recentRemoteRecord struct {
|
|||||||
LocalVaultPath string `json:"localVaultPath,omitempty"`
|
LocalVaultPath string `json:"localVaultPath,omitempty"`
|
||||||
RemoteProfileID string `json:"remoteProfileId,omitempty"`
|
RemoteProfileID string `json:"remoteProfileId,omitempty"`
|
||||||
CredentialEntryID string `json:"credentialEntryId,omitempty"`
|
CredentialEntryID string `json:"credentialEntryId,omitempty"`
|
||||||
|
SyncMode string `json:"syncMode,omitempty"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
LastGroup []string `json:"lastGroup,omitempty"`
|
LastGroup []string `json:"lastGroup,omitempty"`
|
||||||
@@ -449,6 +450,7 @@ type ui struct {
|
|||||||
selectedRemoteConnection bool
|
selectedRemoteConnection bool
|
||||||
selectedVaultRemoteProfileID string
|
selectedVaultRemoteProfileID string
|
||||||
selectedVaultRemoteCredentialEntryID string
|
selectedVaultRemoteCredentialEntryID string
|
||||||
|
selectedVaultRemoteSyncMode appstate.SyncMode
|
||||||
securityDialogOpen bool
|
securityDialogOpen bool
|
||||||
remotePrefsDialogOpen bool
|
remotePrefsDialogOpen bool
|
||||||
showSyncPassword bool
|
showSyncPassword bool
|
||||||
@@ -1010,6 +1012,13 @@ func (u *ui) createVaultAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if u.lifecycleMode == "local" {
|
if u.lifecycleMode == "local" {
|
||||||
|
u.selectedVaultRemoteProfileID = ""
|
||||||
|
u.selectedVaultRemoteCredentialEntryID = ""
|
||||||
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||||
|
u.remoteBaseURL.SetText("")
|
||||||
|
u.remotePath.SetText("")
|
||||||
|
u.remoteUsername.SetText("")
|
||||||
|
u.remotePassword.SetText("")
|
||||||
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1051,9 @@ func (u *ui) openVaultAction() error {
|
|||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
u.restoreRecentVaultGroup(path)
|
u.restoreRecentVaultGroup(path)
|
||||||
u.syncSavedRemoteBindingSelection()
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
@@ -1080,6 +1092,9 @@ func (u *ui) startOpenVaultAction() {
|
|||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
u.restoreRecentVaultGroup(path)
|
u.restoreRecentVaultGroup(path)
|
||||||
u.syncSavedRemoteBindingSelection()
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
u.loadSecuritySettingsFromSession()
|
u.loadSecuritySettingsFromSession()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
@@ -1092,6 +1107,9 @@ func (u *ui) saveAction() error {
|
|||||||
if err := u.state.Save(); err != nil {
|
if err := u.state.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1701,6 +1719,7 @@ func (u *ui) loadRecentRemotes() {
|
|||||||
record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath)
|
record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath)
|
||||||
record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
||||||
record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
||||||
|
record.SyncMode = strings.TrimSpace(record.SyncMode)
|
||||||
record.Username = strings.TrimSpace(record.Username)
|
record.Username = strings.TrimSpace(record.Username)
|
||||||
record.Password = strings.TrimSpace(record.Password)
|
record.Password = strings.TrimSpace(record.Password)
|
||||||
if record.BaseURL == "" || record.Path == "" {
|
if record.BaseURL == "" || record.Path == "" {
|
||||||
@@ -1953,6 +1972,7 @@ func (u *ui) noteRecentRemote(baseURL, path string) {
|
|||||||
record.LocalVaultPath = binding.LocalVaultPath
|
record.LocalVaultPath = binding.LocalVaultPath
|
||||||
record.RemoteProfileID = binding.RemoteProfileID
|
record.RemoteProfileID = binding.RemoteProfileID
|
||||||
record.CredentialEntryID = binding.CredentialEntryID
|
record.CredentialEntryID = binding.CredentialEntryID
|
||||||
|
record.SyncMode = string(binding.SyncMode)
|
||||||
}
|
}
|
||||||
if len(record.LastGroup) == 0 {
|
if len(record.LastGroup) == 0 {
|
||||||
record.LastGroup = u.recentRemoteGroup(baseURL, path)
|
record.LastGroup = u.recentRemoteGroup(baseURL, path)
|
||||||
@@ -2112,6 +2132,7 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
|
|||||||
u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath))
|
u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath))
|
||||||
u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
||||||
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
||||||
|
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
|
||||||
u.remotePassword.Mask = '•'
|
u.remotePassword.Mask = '•'
|
||||||
u.selectedRemoteConnection = true
|
u.selectedRemoteConnection = true
|
||||||
if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" {
|
if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" {
|
||||||
@@ -2209,6 +2230,7 @@ func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
|
|||||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||||
RemoteProfileID: profileID,
|
RemoteProfileID: profileID,
|
||||||
CredentialEntryID: entryID,
|
CredentialEntryID: entryID,
|
||||||
|
SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
profile, ok := u.selectedVaultRemoteProfile()
|
profile, ok := u.selectedVaultRemoteProfile()
|
||||||
@@ -2223,9 +2245,29 @@ func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
|
|||||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||||
RemoteProfileID: profile.ID,
|
RemoteProfileID: profile.ID,
|
||||||
CredentialEntryID: entry.ID,
|
CredentialEntryID: entry.ID,
|
||||||
|
SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeUISyncMode(mode appstate.SyncMode) appstate.SyncMode {
|
||||||
|
switch mode {
|
||||||
|
case appstate.SyncModeAutomaticOnOpenSave:
|
||||||
|
return appstate.SyncModeAutomaticOnOpenSave
|
||||||
|
default:
|
||||||
|
return appstate.SyncModeManual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode {
|
||||||
|
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
||||||
|
return appstate.SyncModeAutomaticOnOpenSave
|
||||||
|
}
|
||||||
|
if u.selectedVaultRemoteSyncMode == "" {
|
||||||
|
return appstate.SyncModeAutomaticOnOpenSave
|
||||||
|
}
|
||||||
|
return appstate.SyncModeManual
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) syncSavedRemoteBindingSelection() {
|
func (u *ui) syncSavedRemoteBindingSelection() {
|
||||||
profiles := u.availableRemoteProfiles()
|
profiles := u.availableRemoteProfiles()
|
||||||
entries := u.availableRemoteCredentialEntries()
|
entries := u.availableRemoteCredentialEntries()
|
||||||
@@ -2267,6 +2309,22 @@ func (u *ui) syncSavedRemoteBindingSelection() {
|
|||||||
if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 {
|
if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 {
|
||||||
u.selectedVaultRemoteCredentialEntryID = entries[0].ID
|
u.selectedVaultRemoteCredentialEntryID = entries[0].ID
|
||||||
}
|
}
|
||||||
|
if binding, ok := u.selectedVaultRemoteBinding(); ok {
|
||||||
|
for _, record := range u.recentRemotes {
|
||||||
|
if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.RemoteProfileID) != strings.TrimSpace(binding.RemoteProfileID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.CredentialEntryID) != strings.TrimSpace(binding.CredentialEntryID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) shouldShowSavedRemoteBindingSelectors() bool {
|
func (u *ui) shouldShowSavedRemoteBindingSelectors() bool {
|
||||||
@@ -2362,7 +2420,7 @@ func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) {
|
|||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
CredentialPath: append([]string(nil), u.currentPath...),
|
CredentialPath: append([]string(nil), u.currentPath...),
|
||||||
SyncMode: appstate.SyncModeAutomaticOnOpenSave,
|
SyncMode: u.newRemoteBindingSyncMode(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2377,6 +2435,7 @@ func (u *ui) saveCurrentRemoteBindingAction() error {
|
|||||||
}
|
}
|
||||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||||
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2421,6 +2480,7 @@ func (u *ui) materializeCurrentRemoteCache() error {
|
|||||||
}
|
}
|
||||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||||
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2894,11 +2954,108 @@ func (u *ui) selectedRemoteUsesLocalCache() bool {
|
|||||||
strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != ""
|
strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) currentSessionIsRemote() bool {
|
||||||
|
session, ok := u.state.Session.(interface{ IsRemote() bool })
|
||||||
|
return ok && session.IsRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) resolvedSelectedVaultRemoteBindingForAutoSync() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
|
||||||
|
binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding()
|
||||||
|
if err == nil || !ok {
|
||||||
|
return binding, resolved, ok, err
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
if strings.Contains(message, "resolve remote profile:") || strings.Contains(message, "resolve remote credentials:") {
|
||||||
|
u.selectedVaultRemoteProfileID = ""
|
||||||
|
u.selectedVaultRemoteCredentialEntryID = ""
|
||||||
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||||
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
|
||||||
|
}
|
||||||
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) synchronizeSelectedRemoteBindingOnOpen() error {
|
||||||
|
if u.currentSessionIsRemote() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync()
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeFromRemote(client, resolved.Profile.Path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) synchronizeSelectedRemoteBindingOnSave() error {
|
||||||
|
if u.currentSessionIsRemote() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync()
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeToRemote(client, resolved.Profile.Path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) reapplyResolvedRemoteBinding(binding appstate.RemoteBinding, resolved appstate.ResolvedRemoteBinding) error {
|
||||||
|
_, err := u.state.ConfigureRemoteBinding(appstate.RemoteBindingInput{
|
||||||
|
LocalVaultPath: binding.LocalVaultPath,
|
||||||
|
RemoteProfileID: resolved.Profile.ID,
|
||||||
|
RemoteProfileName: resolved.Profile.Name,
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
RemotePath: resolved.Profile.Path,
|
||||||
|
CredentialEntryID: resolved.Credentials.ID,
|
||||||
|
CredentialTitle: resolved.Credentials.Title,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
CredentialPath: append([]string(nil), resolved.Credentials.Path...),
|
||||||
|
SyncMode: binding.SyncMode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) remoteLifecycleMessage() string {
|
func (u *ui) remoteLifecycleMessage() string {
|
||||||
if u.selectedRemoteUsesLocalCache() {
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings."
|
return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings."
|
||||||
}
|
}
|
||||||
return "Connect to a remote vault, then unlock it with the KeePass master key."
|
return "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) remoteOpenButtonLabel() string {
|
func (u *ui) remoteOpenButtonLabel() string {
|
||||||
@@ -2907,20 +3064,24 @@ func (u *ui) remoteOpenButtonLabel() string {
|
|||||||
if u.selectedRemoteUsesLocalCache() {
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
return "Opening Cached Vault..."
|
return "Opening Cached Vault..."
|
||||||
}
|
}
|
||||||
return "Opening Remote Vault..."
|
return "Opening Cached Vault Setup..."
|
||||||
case u.remoteOpenRetryAvailable():
|
case u.remoteOpenRetryAvailable():
|
||||||
if u.selectedRemoteUsesLocalCache() {
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
return "Retry Cached Vault"
|
return "Retry Cached Vault"
|
||||||
}
|
}
|
||||||
return "Retry Remote Vault"
|
return "Retry Cached Vault Setup"
|
||||||
default:
|
default:
|
||||||
if u.selectedRemoteUsesLocalCache() {
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
return "Open Cached Vault"
|
return "Open Cached Vault"
|
||||||
}
|
}
|
||||||
return "Open Remote Vault"
|
return "Open And Cache Vault"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) remoteLifecycleSetupSummary() string {
|
||||||
|
return "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target."
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) bannerSurface() uiBanner {
|
func (u *ui) bannerSurface() uiBanner {
|
||||||
switch {
|
switch {
|
||||||
case strings.TrimSpace(u.loadingMessage) != "":
|
case strings.TrimSpace(u.loadingMessage) != "":
|
||||||
|
|||||||
+218
-4
@@ -2188,6 +2188,14 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
|||||||
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
||||||
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
|
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
|
||||||
})
|
})
|
||||||
|
u.recentRemotes = []recentRemoteRecord{{
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
LocalVaultPath: path,
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: string(appstate.SyncModeManual),
|
||||||
|
}}
|
||||||
u.vaultPath.SetText(path)
|
u.vaultPath.SetText(path)
|
||||||
u.masterPassword.SetText(key.Password)
|
u.masterPassword.SetText(key.Password)
|
||||||
u.selectedVaultRemoteProfileID = "stale-profile"
|
u.selectedVaultRemoteProfileID = "stale-profile"
|
||||||
@@ -2211,6 +2219,9 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
|||||||
if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
|
if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
|
||||||
t.Fatalf("remotePath = %q, want resolved profile path", got)
|
t.Fatalf("remotePath = %q, want resolved profile path", got)
|
||||||
}
|
}
|
||||||
|
if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual {
|
||||||
|
t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual from matching recent-remote state", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
||||||
@@ -2271,6 +2282,180 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
path := filepath.Join(t.TempDir(), "family.kdbx")
|
||||||
|
localModel := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "WebDAV Sign-In",
|
||||||
|
Username: "tjulian",
|
||||||
|
Password: "token-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://stale.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
writeKDBXMainTestFile(t, path, localModel, key)
|
||||||
|
|
||||||
|
var remoteBytes bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{
|
||||||
|
Entries: []vault.Entry{{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}},
|
||||||
|
}, key); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != "tjulian" || password != "token-1" {
|
||||||
|
t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-1, true)", username, password, ok)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Fatalf("method = %s, want GET", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", "\"v1\"")
|
||||||
|
_, _ = w.Write(remoteBytes.Bytes())
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
localModel.RemoteProfiles[0].BaseURL = server.URL
|
||||||
|
writeKDBXMainTestFile(t, path, localModel, key)
|
||||||
|
|
||||||
|
u := newUIWithState("desktop", &session.Manager{}, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
|
||||||
|
})
|
||||||
|
u.recentRemotes = []recentRemoteRecord{{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
LocalVaultPath: path,
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
|
||||||
|
}}
|
||||||
|
u.vaultPath.SetText(path)
|
||||||
|
u.masterPassword.SetText(key.Password)
|
||||||
|
|
||||||
|
if err := u.openVaultAction(); err != nil {
|
||||||
|
t.Fatalf("openVaultAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := u.state.Session.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Session.Current() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := current.EntryByID("vault-console"); err != nil {
|
||||||
|
t.Fatalf("EntryByID(vault-console) error = %v, want remote entry merged on open", err)
|
||||||
|
}
|
||||||
|
if got := u.remoteBaseURL.Text(); got != server.URL {
|
||||||
|
t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
path := filepath.Join(t.TempDir(), "family.kdbx")
|
||||||
|
localModel := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "WebDAV Sign-In",
|
||||||
|
Username: "tjulian",
|
||||||
|
Password: "token-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://stale.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
writeKDBXMainTestFile(t, path, localModel, key)
|
||||||
|
|
||||||
|
var (
|
||||||
|
savedRemote []byte
|
||||||
|
putCount int
|
||||||
|
)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != "tjulian" || password != "token-1" {
|
||||||
|
t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-1, true)", username, password, ok)
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("ETag", "\"v1\"")
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, key); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(encoded.Bytes())
|
||||||
|
case http.MethodPut:
|
||||||
|
putCount++
|
||||||
|
var err error
|
||||||
|
savedRemote, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(PUT body) error = %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", "\"v2\"")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
localModel.RemoteProfiles[0].BaseURL = server.URL
|
||||||
|
writeKDBXMainTestFile(t, path, localModel, key)
|
||||||
|
|
||||||
|
u := newUIWithState("desktop", &session.Manager{}, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
|
||||||
|
})
|
||||||
|
u.recentRemotes = []recentRemoteRecord{{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
LocalVaultPath: path,
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
|
||||||
|
}}
|
||||||
|
u.vaultPath.SetText(path)
|
||||||
|
u.masterPassword.SetText(key.Password)
|
||||||
|
if err := u.openVaultAction(); err != nil {
|
||||||
|
t.Fatalf("openVaultAction() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := u.state.UpsertEntry(vault.Entry{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}); err != nil {
|
||||||
|
t.Fatalf("UpsertEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.saveAction(); err != nil {
|
||||||
|
t.Fatalf("saveAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if putCount == 0 {
|
||||||
|
t.Fatal("remote PUT count = 0, want automatic remote synchronize on save")
|
||||||
|
}
|
||||||
|
loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedRemote), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBXWithKey(savedRemote) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := loaded.EntryByID("entry-1"); err != nil {
|
||||||
|
t.Fatalf("EntryByID(entry-1) error = %v, want saved entry on remote", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
|
func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -4510,6 +4695,7 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) {
|
|||||||
first.vaultPath.SetText("/vaults/family.kdbx")
|
first.vaultPath.SetText("/vaults/family.kdbx")
|
||||||
first.selectedVaultRemoteProfileID = "remote-profile-1"
|
first.selectedVaultRemoteProfileID = "remote-profile-1"
|
||||||
first.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
|
first.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
|
||||||
|
first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave
|
||||||
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx")
|
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx")
|
||||||
|
|
||||||
second := newUIWithSession("desktop", &session.Manager{})
|
second := newUIWithSession("desktop", &session.Manager{})
|
||||||
@@ -4530,6 +4716,9 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) {
|
|||||||
if record.CredentialEntryID != "remote-creds-1" {
|
if record.CredentialEntryID != "remote-creds-1" {
|
||||||
t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID)
|
t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID)
|
||||||
}
|
}
|
||||||
|
if record.SyncMode != string(appstate.SyncModeAutomaticOnOpenSave) {
|
||||||
|
t.Fatalf("recentRemotes[0].SyncMode = %q, want automatic_on_open_save", record.SyncMode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) {
|
func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) {
|
||||||
@@ -4611,6 +4800,7 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) {
|
|||||||
LocalVaultPath: "/vaults/family.kdbx",
|
LocalVaultPath: "/vaults/family.kdbx",
|
||||||
RemoteProfileID: "remote-profile-1",
|
RemoteProfileID: "remote-profile-1",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
|
||||||
})
|
})
|
||||||
|
|
||||||
if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" {
|
if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" {
|
||||||
@@ -4622,6 +4812,9 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) {
|
|||||||
if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" {
|
if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" {
|
||||||
t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got)
|
t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got)
|
||||||
}
|
}
|
||||||
|
if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave {
|
||||||
|
t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) {
|
func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) {
|
||||||
@@ -5829,13 +6022,13 @@ func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) {
|
|||||||
u := newUIWithSession("desktop", &session.Manager{})
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
u.lifecycleMode = "remote"
|
u.lifecycleMode = "remote"
|
||||||
|
|
||||||
if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" {
|
if got := u.remoteOpenButtonLabel(); got != "Open And Cache Vault" {
|
||||||
t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault")
|
t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open And Cache Vault")
|
||||||
}
|
}
|
||||||
|
|
||||||
u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
|
u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
|
||||||
if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" {
|
if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault Setup" {
|
||||||
t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault")
|
t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault Setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5857,6 +6050,27 @@ func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIRemoteLifecycleMessageUsesLocalFirstSetupLanguageForFirstRemoteOpen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
u.lifecycleMode = "remote"
|
||||||
|
|
||||||
|
if got := u.remoteLifecycleMessage(); got != "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." {
|
||||||
|
t.Fatalf("remoteLifecycleMessage() = %q, want local-first remote setup guidance", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIRemoteLifecycleSetupSummaryExplainsCacheAndBindingFlow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
|
||||||
|
if got := u.remoteLifecycleSetupSummary(); got != "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." {
|
||||||
|
t.Fatalf("remoteLifecycleSetupSummary() = %q, want local-cache bootstrap guidance", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) {
|
func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+26
@@ -169,6 +169,32 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return 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 {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(13), "Local Cache Bootstrap")
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
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), u.remoteLifecycleSetupSummary())
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
|||||||
Reference in New Issue
Block a user