1795 lines
53 KiB
Go
1795 lines
53 KiB
Go
package appui
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gioui.org/widget"
|
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
"git.julianfamily.org/keepassgo/internal/autofillcache"
|
|
"git.julianfamily.org/keepassgo/internal/session"
|
|
"git.julianfamily.org/keepassgo/internal/vault"
|
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
)
|
|
|
|
func (u *ui) noteRecentVault(path string) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return
|
|
}
|
|
if u.recentVaultGroups == nil {
|
|
u.recentVaultGroups = map[string][]string{}
|
|
}
|
|
if u.recentVaultUsedAt == nil {
|
|
u.recentVaultUsedAt = map[string]time.Time{}
|
|
}
|
|
if len(u.currentPath) > 0 {
|
|
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
|
|
} else if _, ok := u.recentVaultGroups[path]; !ok {
|
|
u.recentVaultGroups[path] = nil
|
|
}
|
|
u.recentVaultUsedAt[path] = u.now()
|
|
next := []string{path}
|
|
for _, existing := range u.recentVaults {
|
|
if existing == path {
|
|
continue
|
|
}
|
|
next = append(next, existing)
|
|
if len(next) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentVaults = next
|
|
if len(u.recentVaultClicks) < len(u.recentVaults) {
|
|
u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults))
|
|
}
|
|
u.saveRecentVaults()
|
|
}
|
|
|
|
func (u *ui) loadRecentVaults() {
|
|
if strings.TrimSpace(u.recentVaultsPath) == "" {
|
|
return
|
|
}
|
|
content, err := os.ReadFile(u.recentVaultsPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
u.recentVaults = nil
|
|
u.recentVaultGroups = map[string][]string{}
|
|
u.recentVaultUsedAt = map[string]time.Time{}
|
|
var records []recentVaultRecord
|
|
switch {
|
|
case json.Unmarshal(content, &records) == nil:
|
|
u.applyRecentVaultRecords(records)
|
|
return
|
|
default:
|
|
var paths []string
|
|
if err := json.Unmarshal(content, &paths); err != nil {
|
|
return
|
|
}
|
|
records = make([]recentVaultRecord, 0, len(paths))
|
|
for _, path := range paths {
|
|
records = append(records, recentVaultRecord{Path: path})
|
|
}
|
|
u.applyRecentVaultRecords(records)
|
|
}
|
|
}
|
|
|
|
func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) {
|
|
filtered := make([]string, 0, len(records))
|
|
seen := map[string]bool{}
|
|
for _, record := range records {
|
|
path := strings.TrimSpace(record.Path)
|
|
if path == "" || seen[path] {
|
|
continue
|
|
}
|
|
seen[path] = true
|
|
filtered = append(filtered, path)
|
|
if u.recentVaultGroups == nil {
|
|
u.recentVaultGroups = map[string][]string{}
|
|
}
|
|
if u.recentVaultUsedAt == nil {
|
|
u.recentVaultUsedAt = map[string]time.Time{}
|
|
}
|
|
u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...)
|
|
if usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)); err == nil {
|
|
u.recentVaultUsedAt[path] = usedAt
|
|
}
|
|
if len(filtered) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentVaults = filtered
|
|
if len(u.recentVaultClicks) < len(u.recentVaults) {
|
|
u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults))
|
|
}
|
|
}
|
|
|
|
func (u *ui) loadRecentRemotes() {
|
|
if strings.TrimSpace(u.recentRemotesPath) == "" {
|
|
return
|
|
}
|
|
content, err := os.ReadFile(u.recentRemotesPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var records []recentRemoteRecord
|
|
if err := json.Unmarshal(content, &records); err != nil {
|
|
return
|
|
}
|
|
filtered := make([]recentRemoteRecord, 0, len(records))
|
|
seen := map[string]bool{}
|
|
for _, record := range records {
|
|
record.BaseURL = strings.TrimSpace(record.BaseURL)
|
|
record.Path = strings.TrimSpace(record.Path)
|
|
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 == "" {
|
|
continue
|
|
}
|
|
if record.Username != "" || record.Password != "" {
|
|
record.NeedsMigration = true
|
|
record.Username = ""
|
|
record.Password = ""
|
|
}
|
|
key := record.BaseURL + "|" + record.Path
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
record.LastGroup = append([]string(nil), record.LastGroup...)
|
|
filtered = append(filtered, record)
|
|
if len(filtered) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentRemotes = filtered
|
|
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
|
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
|
}
|
|
}
|
|
|
|
func (u *ui) hasLegacyRecentRemoteCredentialMigration() bool {
|
|
for _, record := range u.recentRemotes {
|
|
if record.NeedsMigration {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (u *ui) saveRecentVaults() {
|
|
if strings.TrimSpace(u.recentVaultsPath) == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil {
|
|
return
|
|
}
|
|
records := make([]recentVaultRecord, 0, len(u.recentVaults))
|
|
for _, path := range u.recentVaults {
|
|
records = append(records, recentVaultRecord{
|
|
Path: path,
|
|
LastGroup: append([]string(nil), u.recentVaultGroups[path]...),
|
|
UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
content, err := json.MarshalIndent(records, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(u.recentVaultsPath, content, 0o600)
|
|
}
|
|
|
|
func (u *ui) saveRecentRemotes() {
|
|
if strings.TrimSpace(u.recentRemotesPath) == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil {
|
|
return
|
|
}
|
|
content, err := json.MarshalIndent(u.recentRemotes, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(u.recentRemotesPath, content, 0o600)
|
|
}
|
|
|
|
func (u *ui) loadUIPreferences() {
|
|
if strings.TrimSpace(u.uiPreferencesPath) == "" {
|
|
return
|
|
}
|
|
content, err := os.ReadFile(u.uiPreferencesPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var prefs uiPreferences
|
|
if err := json.Unmarshal(content, &prefs); err != nil {
|
|
return
|
|
}
|
|
u.groupControlsHidden = prefs.GroupControlsHidden
|
|
u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden
|
|
u.historyHidden = prefs.HistoryHidden
|
|
u.denseLayout = prefs.DenseLayout
|
|
u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis)
|
|
u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode)
|
|
displayDensity := strings.TrimSpace(prefs.DisplayDensity)
|
|
if displayDensity == "" {
|
|
displayDensity = displayDensityForDenseLayout(prefs.DenseLayout)
|
|
}
|
|
u.applyAccessibilityPreferences(accessibilityPreferences{
|
|
DisplayDensity: displayDensity,
|
|
Contrast: prefs.Contrast,
|
|
ReducedMotion: prefs.ReducedMotion,
|
|
KeyboardFocus: prefs.KeyboardFocus,
|
|
})
|
|
if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" {
|
|
u.autofillFirstFillApprovalMode = mode
|
|
}
|
|
u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist))
|
|
u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist))
|
|
u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules))
|
|
}
|
|
|
|
func (u *ui) saveUIPreferences() {
|
|
if strings.TrimSpace(u.uiPreferencesPath) == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil {
|
|
return
|
|
}
|
|
content, err := json.MarshalIndent(uiPreferences{
|
|
GroupControlsHidden: u.groupControlsHidden,
|
|
LifecycleAdvancedHidden: u.lifecycleAdvancedHidden,
|
|
HistoryHidden: u.historyHidden,
|
|
DenseLayout: u.denseLayout,
|
|
StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond),
|
|
AutofillNoticeMode: string(u.autofillNoticePreference),
|
|
DisplayDensity: u.accessibilityPrefs.DisplayDensity,
|
|
Contrast: u.accessibilityPrefs.Contrast,
|
|
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
|
|
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
|
|
AutofillPrivacy: autofillPrivacySettings{
|
|
FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode),
|
|
BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()),
|
|
AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()),
|
|
PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()),
|
|
},
|
|
}, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(u.uiPreferencesPath, content, 0o600)
|
|
}
|
|
|
|
func (u *ui) loadSettingsFormFromPreferences() {
|
|
u.settingsGroupControls.Value = u.groupControlsHidden
|
|
u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden
|
|
u.settingsHistory.Value = u.historyHidden
|
|
u.settingsDenseLayout.Value = u.denseLayout
|
|
}
|
|
|
|
func (u *ui) applySettingsFormToPreferences() {
|
|
u.groupControlsHidden = u.settingsGroupControls.Value
|
|
u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value
|
|
u.historyHidden = u.settingsHistory.Value
|
|
u.denseLayout = u.settingsDenseLayout.Value
|
|
}
|
|
|
|
func normalizedStatusBannerTTL(valueMillis int) time.Duration {
|
|
switch {
|
|
case valueMillis <= 0:
|
|
return statusBannerDuration
|
|
case time.Duration(valueMillis)*time.Millisecond > statusBannerLong:
|
|
return statusBannerLong
|
|
default:
|
|
return time.Duration(valueMillis) * time.Millisecond
|
|
}
|
|
}
|
|
|
|
func normalizedAutofillNoticeMode(value string) autofillNoticeMode {
|
|
switch autofillNoticeMode(strings.TrimSpace(value)) {
|
|
case autofillNoticeApprovals:
|
|
return autofillNoticeApprovals
|
|
case autofillNoticeSuppressed:
|
|
return autofillNoticeSuppressed
|
|
default:
|
|
return autofillNoticeAll
|
|
}
|
|
}
|
|
|
|
func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode {
|
|
switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) {
|
|
case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock:
|
|
return autofillFirstFillApprovalMode(strings.TrimSpace(raw))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func autofillPrivacyLines(text string) []string {
|
|
lines := strings.Split(text, "\n")
|
|
result := make([]string, 0, len(lines))
|
|
seen := make(map[string]struct{}, len(lines))
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[line]; ok {
|
|
continue
|
|
}
|
|
seen[line] = struct{}{}
|
|
result = append(result, line)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func joinAutofillPrivacyLines(lines []string) string {
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n")
|
|
}
|
|
|
|
func (u *ui) autofillRuleCount() int {
|
|
return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) +
|
|
len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) +
|
|
len(autofillPrivacyLines(u.autofillPackageRules.Text()))
|
|
}
|
|
|
|
func (u *ui) autofillFirstFillApprovalSummary() string {
|
|
switch u.autofillFirstFillApprovalMode {
|
|
case autofillFirstFillApprovalAllow:
|
|
return "New apps and packages can fill immediately until a persistent rule is created."
|
|
case autofillFirstFillApprovalBlock:
|
|
return "New apps and packages stay blocked until you add an allowlist entry or a package rule."
|
|
default:
|
|
return "KeePassGO asks before the first fill into a newly seen app or package."
|
|
}
|
|
}
|
|
|
|
func (u *ui) setStatusBannerTTL(value time.Duration) {
|
|
u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond))
|
|
u.saveUIPreferences()
|
|
}
|
|
|
|
func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) {
|
|
u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value))
|
|
u.saveUIPreferences()
|
|
}
|
|
|
|
func (u *ui) noteRecentRemote(baseURL, path string) {
|
|
baseURL = strings.TrimSpace(baseURL)
|
|
path = strings.TrimSpace(path)
|
|
if baseURL == "" || path == "" {
|
|
return
|
|
}
|
|
record := recentRemoteRecord{
|
|
BaseURL: baseURL,
|
|
Path: path,
|
|
LastGroup: append([]string(nil), u.currentPath...),
|
|
UsedAt: u.now().Format(time.RFC3339Nano),
|
|
}
|
|
if binding, ok := u.selectedVaultRemoteBinding(); ok {
|
|
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)
|
|
}
|
|
next := []recentRemoteRecord{record}
|
|
for _, existing := range u.recentRemotes {
|
|
if existing.BaseURL == baseURL && existing.Path == path {
|
|
continue
|
|
}
|
|
next = append(next, existing)
|
|
if len(next) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentRemotes = next
|
|
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
|
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
|
}
|
|
u.saveRecentRemotes()
|
|
}
|
|
|
|
func (u *ui) recentRemoteGroup(baseURL, path string) []string {
|
|
baseURL = strings.TrimSpace(baseURL)
|
|
path = strings.TrimSpace(path)
|
|
for _, record := range u.recentRemotes {
|
|
if record.BaseURL == baseURL && record.Path == path {
|
|
return append([]string(nil), record.LastGroup...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) restoreStartupLifecycleTarget() {
|
|
localPath, localUsedAt := u.latestRecentVault()
|
|
remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote()
|
|
|
|
switch {
|
|
case hasRemote && strings.TrimSpace(remoteRecord.LocalVaultPath) != "" && (localPath == "" || remoteUsedAt.After(localUsedAt)):
|
|
u.lifecycleMode = "local"
|
|
u.vaultPath.SetText(strings.TrimSpace(remoteRecord.LocalVaultPath))
|
|
case localPath != "":
|
|
u.lifecycleMode = "local"
|
|
u.vaultPath.SetText(localPath)
|
|
case hasRemote:
|
|
u.lifecycleMode = "remote"
|
|
u.applyRecentRemoteRecord(remoteRecord)
|
|
}
|
|
}
|
|
|
|
func (u *ui) hasSelectedLifecycleTarget() bool {
|
|
switch strings.TrimSpace(u.lifecycleMode) {
|
|
case "remote":
|
|
return u.hasSelectedRemoteTarget()
|
|
default:
|
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
|
}
|
|
}
|
|
|
|
func (u *ui) hasSelectedRemoteTarget() bool {
|
|
return u.selectedRemoteConnection
|
|
}
|
|
|
|
func (u *ui) latestRecentVault() (string, time.Time) {
|
|
for _, path := range u.recentVaults {
|
|
if strings.TrimSpace(path) == "" {
|
|
continue
|
|
}
|
|
return path, u.recentVaultUsedAt[path]
|
|
}
|
|
return "", time.Time{}
|
|
}
|
|
|
|
func (u *ui) hasSelectedVaultPath() bool {
|
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
|
}
|
|
|
|
func (u *ui) showLocalVaultChooser() bool {
|
|
return u.lifecycleMode != "local" || !u.hasSelectedVaultPath()
|
|
}
|
|
|
|
func (u *ui) showRemoteConnectionChooser() bool {
|
|
return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget()
|
|
}
|
|
|
|
func (u *ui) switchToLifecycleSelection(mode string) {
|
|
u.state.Session = &session.Manager{}
|
|
u.state.CurrentPath = nil
|
|
u.state.SelectedEntryID = ""
|
|
u.state.Section = appstate.SectionEntries
|
|
u.state.Dirty = false
|
|
u.state.ErrorMessage = ""
|
|
u.state.StatusMessage = ""
|
|
u.loadingMessage = ""
|
|
u.loadingActionLabel = ""
|
|
u.lastLifecycleAction = ""
|
|
u.lifecycleMode = mode
|
|
u.editingEntry = false
|
|
u.currentPath = nil
|
|
u.syncedPath = nil
|
|
u.clearMasterPassword()
|
|
u.keyFilePath.SetText("")
|
|
u.search.SetText("")
|
|
switch mode {
|
|
case "remote":
|
|
u.vaultPath.SetText("")
|
|
u.remoteBaseURL.SetText("")
|
|
u.remotePath.SetText("")
|
|
u.remoteUsername.SetText("")
|
|
u.remotePassword.SetText("")
|
|
u.selectedRemoteConnection = false
|
|
default:
|
|
u.vaultPath.SetText("")
|
|
u.remoteBaseURL.SetText("")
|
|
u.remotePath.SetText("")
|
|
u.remoteUsername.SetText("")
|
|
u.remotePassword.SetText("")
|
|
u.selectedRemoteConnection = false
|
|
}
|
|
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
|
u.filter()
|
|
}
|
|
|
|
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
|
|
for _, record := range u.recentRemotes {
|
|
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
|
|
continue
|
|
}
|
|
usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt))
|
|
if err != nil {
|
|
usedAt = time.Time{}
|
|
}
|
|
return record, true, usedAt
|
|
}
|
|
return recentRemoteRecord{}, false, time.Time{}
|
|
}
|
|
|
|
func (u *ui) currentRemoteRecord() recentRemoteRecord {
|
|
return recentRemoteRecord{
|
|
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
Path: strings.TrimSpace(u.remotePath.Text()),
|
|
}
|
|
}
|
|
|
|
func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
|
|
u.remoteBaseURL.SetText(record.BaseURL)
|
|
u.remotePath.SetText(record.Path)
|
|
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) == "" {
|
|
u.showStatusMessage("This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it.")
|
|
}
|
|
}
|
|
|
|
func (u *ui) remotePreferencesCurrentSummary() string {
|
|
switch {
|
|
case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "":
|
|
return "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile."
|
|
default:
|
|
return "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state."
|
|
}
|
|
}
|
|
|
|
func (u *ui) remotePreferencesAlwaysSavedSummary() string {
|
|
return "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection."
|
|
}
|
|
|
|
func (u *ui) remotePreferencesRetentionSummary() string {
|
|
return "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls."
|
|
}
|
|
|
|
func (u *ui) remotePreferencesPersistenceSummary() string {
|
|
return "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself."
|
|
}
|
|
|
|
func (u *ui) availableRemoteProfiles() []vault.RemoteProfile {
|
|
profiles, err := u.state.RemoteProfiles()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return profiles
|
|
}
|
|
|
|
func (u *ui) availableRemoteCredentialEntries() []vault.Entry {
|
|
entries, err := u.state.RemoteCredentialEntries()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func normalizeRemoteCredentialURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
raw = strings.TrimRight(raw, "/")
|
|
return raw
|
|
}
|
|
|
|
func remoteCredentialURLMatches(candidate, target string) bool {
|
|
candidate = normalizeRemoteCredentialURL(candidate)
|
|
target = normalizeRemoteCredentialURL(target)
|
|
if candidate == "" || target == "" {
|
|
return false
|
|
}
|
|
if candidate == target {
|
|
return true
|
|
}
|
|
candidateURL, err := url.Parse(candidate)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
targetURL, err := url.Parse(target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) {
|
|
return false
|
|
}
|
|
candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/")
|
|
targetPath := strings.TrimRight(targetURL.EscapedPath(), "/")
|
|
if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath)
|
|
}
|
|
|
|
func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
|
|
if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote {
|
|
return nil
|
|
}
|
|
baseURL := normalizeRemoteCredentialURL(u.syncRemoteBaseURL.Text())
|
|
if baseURL == "" {
|
|
return nil
|
|
}
|
|
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
|
entries := u.availableRemoteCredentialEntries()
|
|
byID := u.remoteCredentialEntryMap(entries)
|
|
matches := make([]vault.Entry, 0, len(entries))
|
|
seen := make(map[string]struct{}, len(entries))
|
|
appendMatch := func(entry vault.Entry) {
|
|
u.appendRemoteCredentialMatch(&matches, seen, entry)
|
|
}
|
|
u.appendURLMatchedRemoteCredentials(baseURL, entries, appendMatch)
|
|
profilesByID := u.remoteProfileMap()
|
|
localVaultPath := strings.TrimSpace(u.vaultPath.Text())
|
|
for _, record := range u.recentRemotes {
|
|
if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath {
|
|
continue
|
|
}
|
|
profile, ok := profilesByID[strings.TrimSpace(record.RemoteProfileID)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !remoteCredentialURLMatches(profile.BaseURL, baseURL) {
|
|
continue
|
|
}
|
|
if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath {
|
|
continue
|
|
}
|
|
entry, ok := byID[strings.TrimSpace(record.CredentialEntryID)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
appendMatch(entry)
|
|
}
|
|
return matches
|
|
}
|
|
|
|
func (u *ui) validRemoteProfileSelection(profiles []vault.RemoteProfile) string {
|
|
selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
|
|
if u.hasRemoteProfileSelection(selectedID, profiles) {
|
|
return selectedID
|
|
}
|
|
if len(profiles) == 1 {
|
|
return profiles[0].ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (u *ui) validRemoteCredentialSelection(entries []vault.Entry) string {
|
|
selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
|
|
if u.hasRemoteCredentialSelection(selectedID, entries) {
|
|
return selectedID
|
|
}
|
|
if len(entries) == 1 {
|
|
return entries[0].ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (u *ui) hasRemoteProfileSelection(selectedID string, profiles []vault.RemoteProfile) bool {
|
|
for _, profile := range profiles {
|
|
if profile.ID == selectedID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (u *ui) hasRemoteCredentialSelection(selectedID string, entries []vault.Entry) bool {
|
|
for _, entry := range entries {
|
|
if entry.ID == selectedID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (u *ui) applySelectedRemoteProfileFields() {
|
|
if profile, ok := u.selectedVaultRemoteProfile(); ok {
|
|
u.remoteBaseURL.SetText(profile.BaseURL)
|
|
u.remotePath.SetText(profile.Path)
|
|
}
|
|
}
|
|
|
|
func (u *ui) syncRecentRemoteBindingSelection() {
|
|
if strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" {
|
|
return
|
|
}
|
|
record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text()))
|
|
if !ok {
|
|
return
|
|
}
|
|
u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
|
|
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
|
|
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
|
|
u.applySelectedRemoteProfileFields()
|
|
}
|
|
|
|
func (u *ui) syncSelectedRemoteBindingMode() {
|
|
binding, ok := u.selectedVaultRemoteBinding()
|
|
if !ok {
|
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
|
return
|
|
}
|
|
for _, record := range u.recentRemotes {
|
|
if strings.TrimSpace(record.LocalVaultPath) == strings.TrimSpace(binding.LocalVaultPath) &&
|
|
strings.TrimSpace(record.RemoteProfileID) == strings.TrimSpace(binding.RemoteProfileID) &&
|
|
strings.TrimSpace(record.CredentialEntryID) == strings.TrimSpace(binding.CredentialEntryID) {
|
|
u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
|
|
return
|
|
}
|
|
}
|
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
|
}
|
|
|
|
func (u *ui) remoteCredentialEntryMap(entries []vault.Entry) map[string]vault.Entry {
|
|
byID := make(map[string]vault.Entry, len(entries))
|
|
for _, entry := range entries {
|
|
byID[entry.ID] = entry
|
|
}
|
|
return byID
|
|
}
|
|
|
|
func (u *ui) remoteProfileMap() map[string]vault.RemoteProfile {
|
|
profilesByID := make(map[string]vault.RemoteProfile)
|
|
for _, profile := range u.availableRemoteProfiles() {
|
|
profilesByID[profile.ID] = profile
|
|
}
|
|
return profilesByID
|
|
}
|
|
|
|
func (u *ui) appendRemoteCredentialMatch(matches *[]vault.Entry, seen map[string]struct{}, entry vault.Entry) {
|
|
if strings.TrimSpace(entry.ID) == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[entry.ID]; ok {
|
|
return
|
|
}
|
|
seen[entry.ID] = struct{}{}
|
|
*matches = append(*matches, entry)
|
|
}
|
|
|
|
func (u *ui) appendURLMatchedRemoteCredentials(baseURL string, entries []vault.Entry, appendMatch func(vault.Entry)) {
|
|
for _, entry := range entries {
|
|
if remoteCredentialURLMatches(entry.URL, baseURL) {
|
|
appendMatch(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) {
|
|
u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID)
|
|
u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username))
|
|
u.syncRemotePassword.SetText(entry.Password)
|
|
}
|
|
|
|
func (u *ui) savedAdvancedSyncRemoteBinding() (appstate.ResolvedRemoteBinding, bool) {
|
|
if !u.hasOpenVault() {
|
|
return appstate.ResolvedRemoteBinding{}, false
|
|
}
|
|
_, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding()
|
|
if err != nil || !ok {
|
|
return appstate.ResolvedRemoteBinding{}, false
|
|
}
|
|
return resolved, true
|
|
}
|
|
|
|
func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() {
|
|
resolved, ok := u.savedAdvancedSyncRemoteBinding()
|
|
if !ok {
|
|
return
|
|
}
|
|
u.syncRemoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
u.syncRemotePath.SetText(resolved.Profile.Path)
|
|
u.syncRemoteUsername.SetText(resolved.Credentials.Username)
|
|
u.syncRemotePassword.SetText(resolved.Credentials.Password)
|
|
u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(resolved.Credentials.ID)
|
|
}
|
|
|
|
func (u *ui) syncDialogTitle() string {
|
|
switch {
|
|
case u.syncDialogPurpose == syncDialogPurposeRemoteSetup:
|
|
if _, ok := u.selectedVaultRemoteBinding(); ok {
|
|
return "Remote Sync Settings"
|
|
}
|
|
return "Set Up Remote Sync"
|
|
default:
|
|
return "Advanced Sync"
|
|
}
|
|
}
|
|
|
|
func (u *ui) syncDialogDescription() string {
|
|
switch {
|
|
case u.syncDialogPurpose == syncDialogPurposeRemoteSetup:
|
|
if _, ok := u.selectedVaultRemoteBinding(); ok {
|
|
return "Review or change this vault's saved WebDAV target, credentials, and sync mode."
|
|
}
|
|
return "Send this local vault to a WebDAV target, then use that target for future sync."
|
|
default:
|
|
return "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings."
|
|
}
|
|
}
|
|
|
|
func (u *ui) syncDialogConfirmButtonLabel() string {
|
|
switch {
|
|
case u.syncDialogPurpose == syncDialogPurposeRemoteSetup:
|
|
if _, ok := u.selectedVaultRemoteBinding(); ok {
|
|
return "Save Remote Sync Settings"
|
|
}
|
|
return "Set Up Remote Sync"
|
|
default:
|
|
return "Synchronize"
|
|
}
|
|
}
|
|
|
|
func (u *ui) shouldShowSyncDirectionChoices() bool {
|
|
return u.syncDialogPurpose != syncDialogPurposeRemoteSetup
|
|
}
|
|
|
|
func (u *ui) shouldShowSyncSourceChoices() bool {
|
|
return u.syncDialogPurpose != syncDialogPurposeRemoteSetup
|
|
}
|
|
|
|
func (u *ui) syncSetupMode() appstate.SyncMode {
|
|
if u.syncSetupAutomatic.Value {
|
|
return appstate.SyncModeAutomaticOnOpenSave
|
|
}
|
|
return appstate.SyncModeManual
|
|
}
|
|
|
|
func (u *ui) selectVaultRemoteProfile(id string) {
|
|
u.selectedVaultRemoteProfileID = strings.TrimSpace(id)
|
|
u.applySelectedRemoteProfileFields()
|
|
}
|
|
|
|
func (u *ui) selectVaultRemoteCredentialEntry(id string) {
|
|
u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id)
|
|
}
|
|
|
|
func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) {
|
|
profiles := u.availableRemoteProfiles()
|
|
id := u.validRemoteProfileSelection(profiles)
|
|
if id == "" {
|
|
return vault.RemoteProfile{}, false
|
|
}
|
|
for _, profile := range profiles {
|
|
if profile.ID == id {
|
|
return profile, true
|
|
}
|
|
}
|
|
return vault.RemoteProfile{}, false
|
|
}
|
|
|
|
func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) {
|
|
entries := u.availableRemoteCredentialEntries()
|
|
id := u.validRemoteCredentialSelection(entries)
|
|
if id == "" {
|
|
return vault.Entry{}, false
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.ID == id {
|
|
return entry, true
|
|
}
|
|
}
|
|
return vault.Entry{}, false
|
|
}
|
|
|
|
func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
|
|
localVaultPath := strings.TrimSpace(u.vaultPath.Text())
|
|
profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
|
|
if profileID == "" {
|
|
profileID = u.validRemoteProfileSelection(u.availableRemoteProfiles())
|
|
}
|
|
credentialID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
|
|
if credentialID == "" {
|
|
credentialID = u.validRemoteCredentialSelection(u.availableRemoteCredentialEntries())
|
|
}
|
|
if profileID == "" || credentialID == "" {
|
|
return appstate.RemoteBinding{}, false
|
|
}
|
|
if localVaultPath == "" {
|
|
for _, record := range u.recentRemotes {
|
|
if strings.TrimSpace(record.RemoteProfileID) == profileID &&
|
|
strings.TrimSpace(record.CredentialEntryID) == credentialID &&
|
|
strings.TrimSpace(record.LocalVaultPath) != "" {
|
|
localVaultPath = strings.TrimSpace(record.LocalVaultPath)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if localVaultPath == "" {
|
|
localVaultPath, _ = u.latestRecentVault()
|
|
}
|
|
return appstate.RemoteBinding{
|
|
LocalVaultPath: localVaultPath,
|
|
RemoteProfileID: profileID,
|
|
CredentialEntryID: credentialID,
|
|
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 u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
|
return u.syncSetupMode()
|
|
}
|
|
return normalizeUISyncMode(u.selectedVaultRemoteSyncMode)
|
|
}
|
|
|
|
func (u *ui) syncSavedRemoteBindingSelection() {
|
|
profiles := u.availableRemoteProfiles()
|
|
entries := u.availableRemoteCredentialEntries()
|
|
u.syncRecentRemoteBindingSelection()
|
|
u.selectedVaultRemoteProfileID = u.validRemoteProfileSelection(profiles)
|
|
u.selectedVaultRemoteCredentialEntryID = u.validRemoteCredentialSelection(entries)
|
|
u.syncSelectedRemoteBindingMode()
|
|
u.applySelectedRemoteProfileFields()
|
|
}
|
|
|
|
func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) {
|
|
return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path)
|
|
}
|
|
|
|
func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool {
|
|
_, ok := boundRecentRemoteForLocalVaultRecords(records, path)
|
|
return ok
|
|
}
|
|
|
|
func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return recentRemoteRecord{}, false
|
|
}
|
|
for _, record := range records {
|
|
if strings.TrimSpace(record.LocalVaultPath) == path &&
|
|
strings.TrimSpace(record.RemoteProfileID) != "" &&
|
|
strings.TrimSpace(record.CredentialEntryID) != "" {
|
|
return record, true
|
|
}
|
|
}
|
|
return recentRemoteRecord{}, false
|
|
}
|
|
|
|
func (u *ui) shouldShowSavedRemoteBindingSelectors() bool {
|
|
profiles := u.availableRemoteProfiles()
|
|
entries := u.availableRemoteCredentialEntries()
|
|
if len(profiles) == 0 || len(entries) == 0 {
|
|
return false
|
|
}
|
|
return len(profiles) > 1 || len(entries) > 1
|
|
}
|
|
|
|
func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) {
|
|
summary := u.computeSavedRemoteBindingSummary()
|
|
return summary.ProfileLabel, summary.CredentialLabel, summary.SyncLabel, summary.OK
|
|
}
|
|
|
|
func (u *ui) savedRemoteBindingHeading() string {
|
|
return u.buildSyncMenuModel().SavedBindingHeading()
|
|
}
|
|
|
|
func (u *ui) openSelectedVaultRemoteButtonLabel() string {
|
|
return u.buildSyncMenuModel().OpenSelectedButtonLabel()
|
|
}
|
|
|
|
func (u *ui) shouldShowDirectRemoteSyncShortcut() bool {
|
|
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
|
return false
|
|
}
|
|
return u.buildSyncMenuModel().ShowDirectRemoteSyncShortcut()
|
|
}
|
|
|
|
func (u *ui) directRemoteSyncShortcutLabel() string {
|
|
return u.buildSyncMenuModel().DirectRemoteSyncShortcutLabel()
|
|
}
|
|
|
|
func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool {
|
|
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
|
return false
|
|
}
|
|
return u.buildSyncMenuModel().ShowRemoteSyncSettingsShortcut()
|
|
}
|
|
|
|
func (u *ui) remoteSyncSettingsShortcutLabel() string {
|
|
return u.buildSyncMenuModel().RemoteSyncSettingsShortcutLabel()
|
|
}
|
|
|
|
func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool {
|
|
return u.buildSyncMenuModel().ShowRemoveRemoteSyncShortcut()
|
|
}
|
|
|
|
func (u *ui) removeRemoteSyncShortcutLabel() string {
|
|
return u.buildSyncMenuModel().RemoveRemoteSyncShortcutLabel()
|
|
}
|
|
|
|
func (u *ui) shouldShowRemoteSyncSetupShortcut() bool {
|
|
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
|
return false
|
|
}
|
|
return u.buildSyncMenuModel().ShowRemoteSyncSetupShortcut()
|
|
}
|
|
|
|
func (u *ui) remoteSyncSetupShortcutLabel() string {
|
|
return u.buildSyncMenuModel().RemoteSyncSetupShortcutLabel()
|
|
}
|
|
|
|
func (u *ui) syncMenuActionLabels() []string {
|
|
return u.buildSyncMenuModel().ActionLabels()
|
|
}
|
|
|
|
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: u.newRemoteBindingSyncMode(),
|
|
}, 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
|
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) stripRecentRemoteBinding(binding appstate.RemoteBinding) {
|
|
localPath := strings.TrimSpace(binding.LocalVaultPath)
|
|
profileID := strings.TrimSpace(binding.RemoteProfileID)
|
|
credentialID := strings.TrimSpace(binding.CredentialEntryID)
|
|
for i := range u.recentRemotes {
|
|
record := &u.recentRemotes[i]
|
|
if strings.TrimSpace(record.LocalVaultPath) != localPath {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(record.RemoteProfileID) != profileID {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(record.CredentialEntryID) != credentialID {
|
|
continue
|
|
}
|
|
record.LocalVaultPath = ""
|
|
record.RemoteProfileID = ""
|
|
record.CredentialEntryID = ""
|
|
record.SyncMode = ""
|
|
}
|
|
}
|
|
|
|
func (u *ui) removeSelectedRemoteBindingAction() error {
|
|
binding, ok := u.selectedVaultRemoteBinding()
|
|
if !ok {
|
|
return fmt.Errorf("no saved remote sync target is selected")
|
|
}
|
|
if err := u.state.RemoveRemoteBinding(binding); err != nil {
|
|
return err
|
|
}
|
|
if err := u.state.Save(); err != nil {
|
|
return err
|
|
}
|
|
u.stripRecentRemoteBinding(binding)
|
|
u.selectedVaultRemoteProfileID = ""
|
|
u.selectedVaultRemoteCredentialEntryID = ""
|
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
|
u.remoteUsername.SetText("")
|
|
u.remotePassword.SetText("")
|
|
u.showStatusMessage("Remote sync is no longer set up for this vault.")
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) saveCurrentRemoteBindingHeading() string {
|
|
return u.buildSyncMenuModel().SaveCurrentRemoteBindingHeading()
|
|
}
|
|
|
|
func (u *ui) saveCurrentRemoteBindingButtonLabel() string {
|
|
return u.buildSyncMenuModel().SaveCurrentRemoteBindingButtonLabel()
|
|
}
|
|
|
|
func (u *ui) materializeCurrentRemoteCache() error {
|
|
cachePath := strings.TrimSpace(u.vaultPath.Text())
|
|
if cachePath == "" {
|
|
cachePath = u.saveAsTargetPath()
|
|
}
|
|
if cachePath == "" {
|
|
return nil
|
|
}
|
|
u.vaultPath.SetText(cachePath)
|
|
if err := u.state.SaveAs(cachePath); err != nil {
|
|
return err
|
|
}
|
|
u.noteRecentVault(cachePath)
|
|
|
|
username := strings.TrimSpace(u.remoteUsername.Text())
|
|
password := u.remotePassword.Text()
|
|
if username == "" && password == "" {
|
|
return nil
|
|
}
|
|
|
|
input, err := u.currentRemoteBindingInput()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
binding, err := u.state.ConfigureRemoteBinding(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := u.state.SaveAs(cachePath); err != nil {
|
|
return err
|
|
}
|
|
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
|
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
|
|
if u.hasOpenVault() {
|
|
return u.resolvedSelectedVaultRemoteBinding()
|
|
}
|
|
|
|
binding, ok := u.selectedVaultRemoteBinding()
|
|
if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
|
|
}
|
|
if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
|
|
}
|
|
u.vaultPath.SetText(binding.LocalVaultPath)
|
|
u.noteRecentVault(binding.LocalVaultPath)
|
|
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
|
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
|
|
}
|
|
resolved, err := binding.Resolve(model)
|
|
if err != nil {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
|
|
}
|
|
return binding, resolved, true, nil
|
|
}
|
|
|
|
func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
|
|
binding, ok := u.selectedVaultRemoteBinding()
|
|
if !ok {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
|
|
}
|
|
resolved, err := binding.Resolve(model)
|
|
if err != nil {
|
|
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
|
|
}
|
|
return binding, resolved, true, nil
|
|
}
|
|
|
|
func (u *ui) noteCurrentRemotePath() {
|
|
status, ok := u.state.Session.(sessionStatus)
|
|
if !ok || !status.IsRemote() || status.IsLocked() {
|
|
return
|
|
}
|
|
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
|
|
path := strings.TrimSpace(u.remotePath.Text())
|
|
if baseURL == "" || path == "" {
|
|
return
|
|
}
|
|
for i := range u.recentRemotes {
|
|
if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path {
|
|
continue
|
|
}
|
|
u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...)
|
|
u.saveRecentRemotes()
|
|
return
|
|
}
|
|
}
|
|
|
|
func (u *ui) recentVaultGroup(path string) []string {
|
|
if u.recentVaultGroups == nil {
|
|
return nil
|
|
}
|
|
return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...)
|
|
}
|
|
|
|
func (u *ui) hiddenVaultRoot() string {
|
|
if u.state.Section != appstate.SectionEntries {
|
|
return ""
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if len(model.EntriesInPath(nil)) != 0 {
|
|
return ""
|
|
}
|
|
groups := model.ChildGroups(nil)
|
|
roots := make([]string, 0, len(groups))
|
|
for _, group := range groups {
|
|
if group == "Recycle Bin" {
|
|
continue
|
|
}
|
|
roots = append(roots, group)
|
|
}
|
|
if len(roots) != 1 {
|
|
return ""
|
|
}
|
|
return roots[0]
|
|
}
|
|
|
|
func (u *ui) enterHiddenVaultRoot() {
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" {
|
|
return
|
|
}
|
|
u.setCurrentPath([]string{root})
|
|
}
|
|
|
|
func (u *ui) restoreRecentVaultGroup(path string) {
|
|
saved := u.recentVaultGroup(path)
|
|
if len(saved) == 0 {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
root := u.hiddenVaultRoot()
|
|
if len(saved) == 1 && root != "" && saved[0] == root {
|
|
u.setCurrentPath(saved)
|
|
return
|
|
}
|
|
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
|
u.setCurrentPath(saved)
|
|
return
|
|
}
|
|
u.enterHiddenVaultRoot()
|
|
}
|
|
|
|
func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
|
|
saved := u.recentRemoteGroup(baseURL, path)
|
|
if len(saved) == 0 {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
root := u.hiddenVaultRoot()
|
|
if len(saved) == 1 && root != "" && saved[0] == root {
|
|
u.setCurrentPath(saved)
|
|
return
|
|
}
|
|
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
|
u.setCurrentPath(saved)
|
|
return
|
|
}
|
|
u.enterHiddenVaultRoot()
|
|
}
|
|
|
|
func (u *ui) restoreEntriesPath(path []string) {
|
|
if len(path) == 0 {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
root := u.hiddenVaultRoot()
|
|
if len(path) == 1 && root != "" && path[0] == root {
|
|
u.setCurrentPath(path)
|
|
return
|
|
}
|
|
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) {
|
|
u.setCurrentPath(path)
|
|
return
|
|
}
|
|
u.enterHiddenVaultRoot()
|
|
}
|
|
|
|
func (u *ui) rememberEntriesSectionState() {
|
|
if u.state.Section != appstate.SectionEntries {
|
|
return
|
|
}
|
|
u.entriesState = entriesSectionState{
|
|
Path: append([]string(nil), u.currentPath...),
|
|
SearchQuery: u.search.Text(),
|
|
SelectedEntryID: u.state.SelectedEntryID,
|
|
Editing: u.editingEntry,
|
|
}
|
|
}
|
|
|
|
func (u *ui) restoreEntriesSectionState() {
|
|
u.search.SetText(u.entriesState.SearchQuery)
|
|
u.restoreEntriesPath(u.entriesState.Path)
|
|
u.state.SelectedEntryID = u.entriesState.SelectedEntryID
|
|
u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != ""
|
|
if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" {
|
|
u.loadSelectedEntryIntoEditor()
|
|
}
|
|
}
|
|
|
|
func (u *ui) displayPath() []string {
|
|
path := append([]string(nil), u.currentPath...)
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" || len(path) == 0 || path[0] != root {
|
|
return path
|
|
}
|
|
return append([]string(nil), path[1:]...)
|
|
}
|
|
|
|
func (u *ui) displayEntryPath(path []string) []string {
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" || len(path) == 0 || path[0] != root {
|
|
return append([]string(nil), path...)
|
|
}
|
|
return append([]string(nil), path[1:]...)
|
|
}
|
|
|
|
func (u *ui) currentGroupDisplayName() string {
|
|
displayPath := u.displayPath()
|
|
if len(displayPath) == 0 {
|
|
return "Vault root (/)"
|
|
}
|
|
return strings.Join(displayPath, " / ")
|
|
}
|
|
|
|
func (u *ui) parentGroupDisplayName() string {
|
|
displayPath := u.displayPath()
|
|
if len(displayPath) <= 1 {
|
|
return "Vault root (/)"
|
|
}
|
|
return strings.Join(displayPath[:len(displayPath)-1], " / ")
|
|
}
|
|
|
|
func (u *ui) createGroupLabel() string {
|
|
if len(u.displayPath()) == 0 {
|
|
return "Create Top-Level Group"
|
|
}
|
|
return "Create Subgroup"
|
|
}
|
|
|
|
func pathHasPrefix(path, prefix []string) bool {
|
|
if len(prefix) > len(path) {
|
|
return false
|
|
}
|
|
return slices.Equal(path[:len(prefix)], prefix)
|
|
}
|
|
|
|
func hasExactGroup(model vault.Model, path []string) bool {
|
|
for _, group := range model.Groups {
|
|
if slices.Equal(group, path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (u *ui) currentGroupDeletionState() (bool, string) {
|
|
u.syncCurrentPath()
|
|
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
|
|
return false, ""
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
path := append([]string(nil), u.currentPath...)
|
|
if len(model.ChildGroups(path)) > 0 {
|
|
return false, "This group contains child groups. Move or delete them before removing the group."
|
|
}
|
|
for _, item := range model.Entries {
|
|
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
|
return false, "This group contains entries. Move or delete them before removing the group."
|
|
}
|
|
}
|
|
for _, item := range model.Templates {
|
|
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
|
return false, "This group contains templates. Move or delete them before removing the group."
|
|
}
|
|
}
|
|
return true, "Deleting this empty group will not remove any entries."
|
|
}
|
|
|
|
func (u *ui) deleteGroupPendingConfirmation() bool {
|
|
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
|
|
}
|
|
|
|
func (u *ui) clearDeleteGroupConfirmation() {
|
|
u.deleteGroupPath = nil
|
|
}
|
|
|
|
func (u *ui) armDeleteCurrentGroupAction() {
|
|
if deletable, _ := u.currentGroupDeletionState(); !deletable {
|
|
return
|
|
}
|
|
u.syncCurrentPath()
|
|
u.deleteGroupPath = append([]string(nil), u.currentPath...)
|
|
u.state.ErrorMessage = ""
|
|
u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")))
|
|
}
|
|
|
|
func (u *ui) runAction(label string, action func() error) {
|
|
if strings.TrimSpace(u.loadingMessage) != "" {
|
|
return
|
|
}
|
|
u.loadingMessage = actionLoadingLabel(label)
|
|
u.loadingActionLabel = strings.TrimSpace(label)
|
|
if err := action(); err != nil {
|
|
u.loadingMessage = ""
|
|
u.loadingActionLabel = ""
|
|
u.state.ErrorMessage = u.describeActionError(label, err)
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
u.loadingMessage = ""
|
|
u.loadingActionLabel = ""
|
|
u.syncAutofillCache()
|
|
u.state.ErrorMessage = ""
|
|
if suppressStatusMessage(label) {
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
u.showStatusMessage(label + " complete")
|
|
}
|
|
|
|
func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) {
|
|
if strings.TrimSpace(u.loadingMessage) != "" {
|
|
return
|
|
}
|
|
u.backgroundActionSerial++
|
|
actionID := u.backgroundActionSerial
|
|
u.activeBackgroundAction = actionID
|
|
u.loadingMessage = actionLoadingLabel(label)
|
|
u.loadingActionLabel = strings.TrimSpace(label)
|
|
u.state.ErrorMessage = ""
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
go func() {
|
|
apply, err := prepare()
|
|
u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID}
|
|
if u.invalidate != nil {
|
|
u.invalidate()
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (u *ui) applyBackgroundResult(result backgroundActionResult) {
|
|
if result.id != 0 && result.id != u.activeBackgroundAction {
|
|
return
|
|
}
|
|
u.activeBackgroundAction = 0
|
|
u.loadingMessage = ""
|
|
u.loadingActionLabel = ""
|
|
if result.err != nil {
|
|
u.state.ErrorMessage = u.describeActionError(result.label, result.err)
|
|
if strings.HasPrefix(result.label, "open ") {
|
|
u.requestMasterPassFocus = true
|
|
}
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
if result.apply != nil {
|
|
if err := result.apply(); err != nil {
|
|
u.state.ErrorMessage = u.describeActionError(result.label, err)
|
|
if strings.HasPrefix(result.label, "open ") {
|
|
u.requestMasterPassFocus = true
|
|
}
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
}
|
|
u.syncAutofillCache()
|
|
u.state.ErrorMessage = ""
|
|
if suppressStatusMessage(result.label) {
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
u.showStatusMessage(result.label + " complete")
|
|
}
|
|
|
|
func (u *ui) cancelLifecycleBusyState() {
|
|
if !u.lifecycleBusy() {
|
|
return
|
|
}
|
|
u.activeBackgroundAction = 0
|
|
u.loadingMessage = ""
|
|
u.loadingActionLabel = ""
|
|
u.state.ErrorMessage = ""
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
u.requestMasterPassFocus = true
|
|
}
|
|
|
|
func (u *ui) retryLastLifecycleOpen() {
|
|
switch strings.TrimSpace(u.lastLifecycleAction) {
|
|
case "open vault":
|
|
u.startOpenVaultAction()
|
|
case "open remote vault":
|
|
u.startOpenRemoteAction()
|
|
}
|
|
}
|
|
|
|
func (u *ui) canRetryLifecycleOpen() bool {
|
|
if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" {
|
|
return false
|
|
}
|
|
switch strings.TrimSpace(u.lastLifecycleAction) {
|
|
case "open vault", "open remote vault":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (u *ui) processBackgroundActions() {
|
|
for {
|
|
select {
|
|
case result := <-u.backgroundResults:
|
|
u.applyBackgroundResult(result)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (u *ui) syncAutofillCache() {
|
|
if strings.TrimSpace(u.autofillCachePath) == "" {
|
|
return
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
_ = autofillcache.Clear(u.autofillCachePath)
|
|
return
|
|
}
|
|
_ = autofillcache.Write(u.autofillCachePath, model, u.now())
|
|
}
|
|
|
|
func suppressStatusMessage(label string) bool {
|
|
switch strings.TrimSpace(label) {
|
|
case "open vault", "open remote vault":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func actionLoadingLabel(label string) string {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return "Working..."
|
|
}
|
|
runes := []rune(label)
|
|
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
|
|
return string(runes) + "..."
|
|
}
|
|
|
|
func (u *ui) describeActionError(label string, err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) {
|
|
return "Save conflict: the remote vault changed. Reopen it and retry the save."
|
|
}
|
|
if label == "open remote vault" {
|
|
return fmt.Sprintf("%s failed: %v", label, err)
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func (u *ui) remoteOpenRetryAvailable() bool {
|
|
return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:")
|
|
}
|
|
|
|
func (u *ui) selectedRemoteUsesLocalCache() bool {
|
|
return u.hasSelectedRemoteTarget() &&
|
|
strings.TrimSpace(u.vaultPath.Text()) != "" &&
|
|
strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" &&
|
|
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 "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 {
|
|
switch {
|
|
case u.lifecycleBusy():
|
|
if u.selectedRemoteUsesLocalCache() {
|
|
return "Opening Cached Vault..."
|
|
}
|
|
return "Creating Local Cache..."
|
|
case u.remoteOpenRetryAvailable():
|
|
if u.selectedRemoteUsesLocalCache() {
|
|
return "Retry Cached Vault"
|
|
}
|
|
return "Retry Local Cache Setup"
|
|
default:
|
|
if u.selectedRemoteUsesLocalCache() {
|
|
return "Open Cached Vault"
|
|
}
|
|
return "Create Local Cache"
|
|
}
|
|
}
|
|
|
|
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."
|
|
}
|