Implement local-first remote sync policy
This commit is contained in:
@@ -159,6 +159,7 @@ type recentRemoteRecord struct {
|
||||
LocalVaultPath string `json:"localVaultPath,omitempty"`
|
||||
RemoteProfileID string `json:"remoteProfileId,omitempty"`
|
||||
CredentialEntryID string `json:"credentialEntryId,omitempty"`
|
||||
SyncMode string `json:"syncMode,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
LastGroup []string `json:"lastGroup,omitempty"`
|
||||
@@ -449,6 +450,7 @@ type ui struct {
|
||||
selectedRemoteConnection bool
|
||||
selectedVaultRemoteProfileID string
|
||||
selectedVaultRemoteCredentialEntryID string
|
||||
selectedVaultRemoteSyncMode appstate.SyncMode
|
||||
securityDialogOpen bool
|
||||
remotePrefsDialogOpen bool
|
||||
showSyncPassword bool
|
||||
@@ -1010,6 +1012,13 @@ func (u *ui) createVaultAction() error {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -1042,6 +1051,9 @@ func (u *ui) openVaultAction() error {
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
@@ -1080,6 +1092,9 @@ func (u *ui) startOpenVaultAction() {
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
@@ -1092,6 +1107,9 @@ func (u *ui) saveAction() error {
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -1701,6 +1719,7 @@ func (u *ui) loadRecentRemotes() {
|
||||
record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath)
|
||||
record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
||||
record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
||||
record.SyncMode = strings.TrimSpace(record.SyncMode)
|
||||
record.Username = strings.TrimSpace(record.Username)
|
||||
record.Password = strings.TrimSpace(record.Password)
|
||||
if record.BaseURL == "" || record.Path == "" {
|
||||
@@ -1953,6 +1972,7 @@ func (u *ui) noteRecentRemote(baseURL, path string) {
|
||||
record.LocalVaultPath = binding.LocalVaultPath
|
||||
record.RemoteProfileID = binding.RemoteProfileID
|
||||
record.CredentialEntryID = binding.CredentialEntryID
|
||||
record.SyncMode = string(binding.SyncMode)
|
||||
}
|
||||
if len(record.LastGroup) == 0 {
|
||||
record.LastGroup = u.recentRemoteGroup(baseURL, path)
|
||||
@@ -2112,6 +2132,7 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
|
||||
u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath))
|
||||
u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
||||
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
||||
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
|
||||
u.remotePassword.Mask = '•'
|
||||
u.selectedRemoteConnection = true
|
||||
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()),
|
||||
RemoteProfileID: profileID,
|
||||
CredentialEntryID: entryID,
|
||||
SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||
}, true
|
||||
}
|
||||
profile, ok := u.selectedVaultRemoteProfile()
|
||||
@@ -2223,9 +2245,29 @@ func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
|
||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||
RemoteProfileID: profile.ID,
|
||||
CredentialEntryID: entry.ID,
|
||||
SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||
}, 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() {
|
||||
profiles := u.availableRemoteProfiles()
|
||||
entries := u.availableRemoteCredentialEntries()
|
||||
@@ -2267,6 +2309,22 @@ func (u *ui) syncSavedRemoteBindingSelection() {
|
||||
if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 {
|
||||
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 {
|
||||
@@ -2362,7 +2420,7 @@ func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) {
|
||||
Username: username,
|
||||
Password: password,
|
||||
CredentialPath: append([]string(nil), u.currentPath...),
|
||||
SyncMode: appstate.SyncModeAutomaticOnOpenSave,
|
||||
SyncMode: u.newRemoteBindingSyncMode(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2377,6 +2435,7 @@ func (u *ui) saveCurrentRemoteBindingAction() error {
|
||||
}
|
||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2421,6 +2480,7 @@ func (u *ui) materializeCurrentRemoteCache() error {
|
||||
}
|
||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2894,11 +2954,108 @@ func (u *ui) selectedRemoteUsesLocalCache() bool {
|
||||
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 {
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
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 {
|
||||
@@ -2907,20 +3064,24 @@ func (u *ui) remoteOpenButtonLabel() string {
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
return "Opening Cached Vault..."
|
||||
}
|
||||
return "Opening Remote Vault..."
|
||||
return "Opening Cached Vault Setup..."
|
||||
case u.remoteOpenRetryAvailable():
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
return "Retry Cached Vault"
|
||||
}
|
||||
return "Retry Remote Vault"
|
||||
return "Retry Cached Vault Setup"
|
||||
default:
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
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 {
|
||||
switch {
|
||||
case strings.TrimSpace(u.loadingMessage) != "":
|
||||
|
||||
Reference in New Issue
Block a user