From ccaee9fa34eeb6efd3ab91552443a0eb6a1c19bd Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 09:20:57 -0700 Subject: [PATCH] Split app UI layout packages --- internal/appui/app.go | 4134 +---------------- internal/appui/layout/detail/mode.go | 26 + .../appui/layout/{ => header}/dropdown.go | 8 +- internal/appui/layout/list/sections.go | 12 + internal/appui/main_test.go | 37 +- internal/appui/ui_actions_lifecycle.go | 778 ++++ internal/appui/ui_frame.go | 1375 ++++++ internal/appui/ui_layout_header.go | 12 +- internal/appui/ui_recent_state.go | 1787 +++++++ internal/appui/ui_runtime.go | 191 + 10 files changed, 4230 insertions(+), 4130 deletions(-) create mode 100644 internal/appui/layout/detail/mode.go rename internal/appui/layout/{ => header}/dropdown.go (92%) create mode 100644 internal/appui/layout/list/sections.go create mode 100644 internal/appui/ui_actions_lifecycle.go create mode 100644 internal/appui/ui_frame.go create mode 100644 internal/appui/ui_recent_state.go create mode 100644 internal/appui/ui_runtime.go diff --git a/internal/appui/app.go b/internal/appui/app.go index 29aff49..9a438b5 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -1,30 +1,20 @@ package appui import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "flag" "fmt" "image" "image/color" - "io" - "net/url" "os" - "os/exec" "path/filepath" "runtime" "slices" "strings" "time" - "gioui.org/app" "gioui.org/gesture" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" - "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" @@ -36,14 +26,13 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" + detaillayout "git.julianfamily.org/keepassgo/internal/appui/layout/detail" + listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" "git.julianfamily.org/keepassgo/internal/appui/platform" keepassassets "git.julianfamily.org/keepassgo/internal/assets" - "git.julianfamily.org/keepassgo/internal/autofillcache" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" - "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" - "git.julianfamily.org/keepassgo/internal/webdav" "golang.org/x/exp/shiny/materialdesign/icons" ) @@ -77,17 +66,6 @@ const ( autofillNoticeSuppressed autofillNoticeMode = "suppressed" ) -type listPanelTopSection string - -const ( - listPanelTopSearch listPanelTopSection = "search" - listPanelTopNavigation listPanelTopSection = "navigation" - listPanelTopPath listPanelTopSection = "path" - listPanelTopGroup listPanelTopSection = "group" - listPanelTopGroupTools listPanelTopSection = "group_tools" - listPanelTopPrimary listPanelTopSection = "primary" -) - type bannerKind string const ( @@ -1036,3886 +1014,6 @@ func (u *ui) currentMasterKey() (vault.MasterKey, error) { func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {} -func (u *ui) createVaultAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - if err := u.state.ConfigureSecurity(vault.SecuritySettings{ - Cipher: strings.TrimSpace(u.securityCipher.Text()), - KDF: strings.TrimSpace(u.securityKDF.Text()), - }); err != nil { - return err - } - if err := u.state.CreateVault(key); err != nil { - 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 - } - u.vaultPath.SetText(u.saveAsTargetPath()) - u.noteRecentVault(u.saveAsTargetPath()) - } - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) openVaultAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.OpenVault(path, key); err != nil { - return err - } - u.noteRecentVault(path) - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.restoreRecentVaultGroup(path) - u.syncSavedRemoteBindingSelection() - if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { - u.showStatusMessage("Remote sync on open failed: " + err.Error()) - } - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - u.applyPendingLifecycleOpenIntent() - return nil -} - -func (u *ui) startOpenVaultAction() { - manager, ok := u.state.Session.(*session.Manager) - if !ok { - u.runAction("open vault", u.openVaultAction) - return - } - key, err := u.currentMasterKey() - u.clearMasterPassword() - if err != nil { - u.state.ErrorMessage = u.describeActionError("open vault", err) - u.requestMasterPassFocus = true - return - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) - u.requestMasterPassFocus = true - return - } - u.lastLifecycleAction = "open vault" - u.runBackgroundAction("open vault", func() (func() error, error) { - prepared, err := session.PrepareLocalOpen(path, key) - if err != nil { - return nil, err - } - return func() error { - manager.ApplyPreparedLocalOpen(prepared) - u.noteRecentVault(path) - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.restoreRecentVaultGroup(path) - u.syncSavedRemoteBindingSelection() - if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { - u.showStatusMessage("Remote sync on open failed: " + err.Error()) - } - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - u.applyPendingLifecycleOpenIntent() - return nil - }, nil - }) -} - -func (u *ui) shouldShowLifecycleRemoteSyncAction() bool { - return strings.TrimSpace(u.vaultPath.Text()) != "" -} - -func (u *ui) lifecycleRemoteSyncActionLabel() string { - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return "Open Vault And Set Up Remote Sync" - } - if hasBoundRecentRemote(u.recentRemotes, path) { - return "Open Vault And Open Remote Sync Settings" - } - return "Open Vault And Set Up Remote Sync" -} - -func (u *ui) beginLifecycleRemoteSyncOpen() { - path := strings.TrimSpace(u.vaultPath.Text()) - switch { - case path == "": - u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone - case hasBoundRecentRemote(u.recentRemotes, path): - u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings - default: - u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup - } - u.startOpenVaultAction() -} - -func (u *ui) applyPendingLifecycleOpenIntent() { - intent := u.pendingLifecycleOpenIntent - u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone - switch intent { - case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings: - u.openRemoteSyncSetupDialog() - } -} - -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 -} - -func (u *ui) saveAsAction() error { - path := u.saveAsTargetPath() - if err := u.state.SaveAs(path); err != nil { - return err - } - u.vaultPath.SetText(path) - u.noteRecentVault(path) - u.filter() - return nil -} - -func (u *ui) openRemoteAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil { - return err - } else if ok { - if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { - return err - } - u.remoteBaseURL.SetText(resolved.Profile.BaseURL) - u.remotePath.SetText(resolved.Profile.Path) - u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil - } - client := webdav.Client{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), - } - if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { - return err - } - if err := u.materializeCurrentRemoteCache(); err != nil { - return err - } - u.noteRecentRemote( - strings.TrimSpace(u.remoteBaseURL.Text()), - strings.TrimSpace(u.remotePath.Text()), - ) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) startOpenRemoteAction() { - manager, ok := u.state.Session.(*session.Manager) - if !ok { - u.runAction("open remote vault", u.openRemoteAction) - return - } - key, err := u.currentMasterKey() - u.clearMasterPassword() - if err != nil { - u.state.ErrorMessage = u.describeActionError("open remote vault", err) - u.requestMasterPassFocus = true - return - } - client := webdav.Client{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), - } - remotePath := strings.TrimSpace(u.remotePath.Text()) - u.lastLifecycleAction = "open remote vault" - u.runBackgroundAction("open remote vault", func() (func() error, error) { - binding, bindingOK := u.selectedVaultRemoteBinding() - if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" { - preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key) - if err != nil { - return nil, err - } - resolved, err := binding.Resolve(preparedLocal.Model) - if err != nil { - return nil, err - } - preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{ - BaseURL: resolved.Profile.BaseURL, - Username: resolved.Credentials.Username, - Password: resolved.Credentials.Password, - }, resolved.Profile.Path, key) - if err != nil { - return nil, err - } - return func() error { - manager.ApplyPreparedLocalOpen(preparedLocal) - u.vaultPath.SetText(binding.LocalVaultPath) - u.noteRecentVault(binding.LocalVaultPath) - u.restoreRecentVaultGroup(binding.LocalVaultPath) - manager.ApplyPreparedRemoteOpen(preparedRemote) - u.remoteBaseURL.SetText(resolved.Profile.BaseURL) - u.remotePath.SetText(resolved.Profile.Path) - u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil - }, nil - } - if u.hasOpenVault() { - if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { - return nil, err - } else if ok { - client = webdav.Client{ - BaseURL: resolved.Profile.BaseURL, - Username: resolved.Credentials.Username, - Password: resolved.Credentials.Password, - } - remotePath = resolved.Profile.Path - u.remoteBaseURL.SetText(resolved.Profile.BaseURL) - u.remotePath.SetText(resolved.Profile.Path) - } - } - prepared, err := session.PrepareRemoteOpen(client, remotePath, key) - if err != nil { - return nil, err - } - return func() error { - manager.ApplyPreparedRemoteOpen(prepared) - if err := u.materializeCurrentRemoteCache(); err != nil { - return err - } - u.noteRecentRemote( - strings.TrimSpace(u.remoteBaseURL.Text()), - remotePath, - ) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil - }, nil - }) -} - -func (u *ui) lockAction() error { - u.clearMasterPassword() - if err := u.state.Lock(); err != nil { - return err - } - u.requestMasterPassFocus = true - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.resetPasswordPeek() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) unlockAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - if err := u.state.Unlock(key); err != nil { - return err - } - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) startUnlockAction() { - manager, ok := u.state.Session.(*session.Manager) - if !ok { - u.runAction("unlock vault", u.unlockAction) - return - } - key, err := u.currentMasterKey() - u.clearMasterPassword() - if err != nil { - u.state.ErrorMessage = u.describeActionError("unlock vault", err) - u.requestMasterPassFocus = true - return - } - encoded := append([]byte(nil), manager.EncodedBytes()...) - u.runBackgroundAction("unlock vault", func() (func() error, error) { - prepared, err := session.PrepareUnlock(encoded, key) - if err != nil { - return nil, err - } - return func() error { - manager.ApplyPreparedUnlock(prepared) - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil - }, nil - }) -} - -func (u *ui) changeMasterKeyAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - return u.state.ChangeMasterKey(key) -} - -func (u *ui) loadSecuritySettingsFromSession() { - settings, err := u.state.SecuritySettings() - if err != nil { - return - } - u.securityCipher.SetText(settings.Cipher) - u.securityKDF.SetText(settings.KDF) -} - -func (u *ui) clearMasterPassword() { - u.masterPassword.SetText("") -} - -func (u *ui) synchronizeAction() error { - if err := u.state.Synchronize(); err != nil { - return err - } - u.syncMenuOpen = false - u.filter() - return nil -} - -func (u *ui) openAdvancedSyncDialog() { - u.syncDialogOpen = true - u.syncMenuOpen = false - u.showSyncPassword = false - u.syncDialogList.Position = layout.Position{} - u.syncDialogPurpose = syncDialogPurposeAdvanced - u.syncSourceMode = u.syncDefaultSourceMode - u.syncDirection = u.syncDefaultDirection - if strings.TrimSpace(u.syncLocalPath.Text()) == "" { - u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) - } - u.syncSavedRemoteBindingSelection() - u.prefillAdvancedSyncRemoteFromSavedBinding() -} - -func (u *ui) openRemoteSyncSetupDialog() { - u.syncDialogOpen = true - u.syncMenuOpen = false - u.showSyncPassword = false - u.syncDialogList.Position = layout.Position{} - u.syncDialogPurpose = syncDialogPurposeRemoteSetup - u.syncSourceMode = syncSourceRemote - u.syncDirection = syncDirectionPush - u.syncSetupAutomatic.Value = true - if strings.TrimSpace(u.syncLocalPath.Text()) == "" { - u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) - } - u.syncSavedRemoteBindingSelection() - u.prefillAdvancedSyncRemoteFromSavedBinding() - if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual { - u.syncSetupAutomatic.Value = false - } -} - -func (u *ui) clearSyncLocalImport() { - u.syncLocalImportName = "" - u.syncLocalImportContent = nil -} - -func (u *ui) selectedSyncLocalImport() (string, []byte, bool) { - name := strings.TrimSpace(u.syncLocalImportName) - if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 { - return "", nil, false - } - return name, append([]byte(nil), u.syncLocalImportContent...), true -} - -func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode { - switch mode { - case syncSourceRemote: - return syncSourceRemote - default: - return syncSourceLocal - } -} - -func sanitizeSyncDirection(direction syncDirection) syncDirection { - switch direction { - case syncDirectionPush: - return syncDirectionPush - default: - return syncDirectionPull - } -} - -func (u *ui) advancedSyncAction() error { - switch u.syncDirection { - case syncDirectionPush: - return u.advancedSyncToAction() - default: - return u.advancedSyncFromAction() - } -} - -func (u *ui) advancedSyncFromAction() error { - switch u.syncSourceMode { - case syncSourceRemote: - client := webdav.Client{ - BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), - Username: strings.TrimSpace(u.syncRemoteUsername.Text()), - Password: u.syncRemotePassword.Text(), - } - if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { - return err - } - default: - if name, content, ok := u.selectedSyncLocalImport(); ok { - if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil { - return err - } - break - } - path := strings.TrimSpace(u.syncLocalPath.Text()) - if path == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.SynchronizeFromLocal(path); err != nil { - return err - } - } - u.syncDialogOpen = false - u.showSyncPassword = false - u.filter() - return nil -} - -func (u *ui) startChooseSyncLocalSourceAction() { - if runtime.GOOS != "android" || u.fileExplorer == nil { - u.runAction("choose sync path", func() error { - u.clearSyncLocalImport() - return u.chooseExistingFileAction(&u.syncLocalPath) - }) - return - } - u.runBackgroundAction("choose sync file", func() (func() error, error) { - file, err := u.fileExplorer.ChooseFile(".kdbx") - if err != nil { - if errors.Is(err, explorer.ErrUserDecline) { - return func() error { return nil }, nil - } - return nil, err - } - defer file.Close() - content, err := io.ReadAll(file) - if err != nil { - return nil, err - } - label := "Selected Android vault" - return func() error { - u.syncLocalImportName = label - u.syncLocalImportContent = append([]byte(nil), content...) - u.syncLocalPath.SetText(label) - return nil - }, nil - }) -} - -func pickedDocumentName(file io.ReadCloser, fallback string) string { - if named, ok := file.(interface{ Name() string }); ok { - if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) { - return base - } - } - fallback = filepath.Base(strings.TrimSpace(fallback)) - if fallback == "" || fallback == "." || fallback == string(filepath.Separator) { - return "selected-vault.kdbx" - } - return fallback -} - -func (u *ui) startChooseVaultPathAction() { - if runtime.GOOS != "android" || u.fileExplorer == nil { - u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) - return - } - u.runBackgroundAction("choose vault file", func() (func() error, error) { - file, err := u.fileExplorer.ChooseFile(".kdbx") - if err != nil { - if errors.Is(err, explorer.ErrUserDecline) { - return func() error { return nil }, nil - } - return nil, err - } - defer file.Close() - content, err := io.ReadAll(file) - if err != nil { - return nil, err - } - name := pickedDocumentName(file, "selected-vault.kdbx") - return func() error { - return u.importSharedVaultBytesAction(name, content) - }, nil - }) -} - -func (u *ui) startImportSharedVaultAction() { - if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil { - return - } - u.runBackgroundAction("import shared vault", func() (func() error, error) { - file, err := u.fileExplorer.ChooseFile(".kdbx") - if err != nil { - if errors.Is(err, explorer.ErrUserDecline) { - return func() error { return nil }, nil - } - return nil, err - } - defer file.Close() - content, err := io.ReadAll(file) - if err != nil { - return nil, err - } - return func() error { - return u.importSharedVaultBytesAction("shared-vault.kdbx", content) - }, nil - }) -} - -func (u *ui) advancedSyncToAction() error { - switch u.syncSourceMode { - case syncSourceRemote: - baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text()) - remotePath := strings.TrimSpace(u.syncRemotePath.Text()) - client := webdav.Client{ - BaseURL: baseURL, - Username: strings.TrimSpace(u.syncRemoteUsername.Text()), - Password: u.syncRemotePassword.Text(), - } - if err := u.state.SynchronizeToRemote(client, remotePath); err != nil { - return err - } - if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { - if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil { - return err - } - u.showStatusMessage("Remote sync is set up for this vault.") - } - default: - path := strings.TrimSpace(u.syncLocalPath.Text()) - if path == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.SynchronizeToLocal(path); err != nil { - return err - } - } - u.syncDialogOpen = false - u.showSyncPassword = false - u.filter() - return nil -} - -func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error { - baseURL = strings.TrimSpace(baseURL) - remotePath = strings.TrimSpace(remotePath) - if baseURL == "" || remotePath == "" { - return fmt.Errorf("remote setup requires base URL and path") - } - input := appstate.RemoteBindingInput{ - LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), - RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), - RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), - BaseURL: baseURL, - RemotePath: remotePath, - CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), - CredentialTitle: "WebDAV Sign-In" + func() string { - if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" { - return " · " + user - } - return "" - }(), - Username: strings.TrimSpace(u.syncRemoteUsername.Text()), - Password: u.syncRemotePassword.Text(), - CredentialPath: append([]string(nil), u.currentPath...), - SyncMode: u.syncSetupMode(), - } - binding, err := u.state.ConfigureRemoteBinding(input) - if err != nil { - return err - } - if err := u.state.Save(); err != nil { - return err - } - u.selectedVaultRemoteProfileID = binding.RemoteProfileID - u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID - u.selectedVaultRemoteSyncMode = binding.SyncMode - u.remoteBaseURL.SetText(baseURL) - u.remotePath.SetText(remotePath) - u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text())) - u.remotePassword.SetText(u.syncRemotePassword.Text()) - u.noteRecentRemote(baseURL, remotePath) - return nil -} - -func (u *ui) saveAsTargetPath() string { - path := strings.TrimSpace(u.saveAsPath.Text()) - if path != "" { - return path - } - return u.defaultSaveAsPath -} - -func (u *ui) importedVaultDestination(name string) string { - baseTarget := u.saveAsTargetPath() - baseDir := filepath.Dir(baseTarget) - baseName := filepath.Base(strings.TrimSpace(name)) - switch { - case baseName == "" || baseName == "." || baseName == string(filepath.Separator): - return baseTarget - case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"): - return filepath.Join(baseDir, baseName) - default: - return baseTarget - } -} - -func (u *ui) consumePendingSharedVaultImport() { - path := strings.TrimSpace(u.pendingSharedVaultPath) - if path == "" { - return - } - content, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) - } - return - } - name := "shared-vault.kdbx" - if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { - if rawName, err := os.ReadFile(namePath); err == nil { - if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" { - name = trimmed - } - } - } - if err := u.importSharedVaultBytesAction(name, content); err != nil { - u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) - return - } - _ = os.Remove(path) - if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { - _ = os.Remove(namePath) - } -} - -func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { - target := u.importedVaultDestination(name) - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { - return err - } - if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil { - return err - } - u.lifecycleMode = "local" - u.vaultPath.SetText(target) - u.noteRecentVault(target) - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.requestMasterPassFocus = true - u.filter() - return nil -} - -func (u *ui) currentShareableVaultPath() string { - return strings.TrimSpace(u.vaultPath.Text()) -} - -func (u *ui) shareCurrentVaultAction() error { - if u.vaultSharer == nil { - return fmt.Errorf("vault sharing is not available on this platform") - } - path := u.currentShareableVaultPath() - if path == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.Save(); err != nil { - return err - } - return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path)) -} - -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.applyAdvancedSyncRemoteCredentialEntry(resolved.Credentials) -} - -func (u *ui) syncDialogTitle() string { - if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { - if _, ok := u.selectedVaultRemoteBinding(); ok { - return "Remote Sync Settings" - } - return "Set Up Remote Sync" - } - return "Advanced Sync" -} - -func (u *ui) syncDialogDescription() string { - if 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." - } - 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 { - if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { - if _, ok := u.selectedVaultRemoteBinding(); ok { - return "Save Remote Sync Settings" - } - return "Set Up Remote Sync" - } - 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) { - id = strings.TrimSpace(id) - u.selectedVaultRemoteProfileID = id - for _, profile := range u.availableRemoteProfiles() { - if profile.ID != id { - continue - } - u.remoteBaseURL.SetText(profile.BaseURL) - u.remotePath.SetText(profile.Path) - return - } -} - -func (u *ui) selectVaultRemoteCredentialEntry(id string) { - u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id) -} - -func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) { - selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) - profiles := u.availableRemoteProfiles() - for _, profile := range profiles { - if profile.ID == selectedID { - return profile, true - } - } - if selectedID == "" && len(profiles) == 1 { - return profiles[0], true - } - return vault.RemoteProfile{}, false -} - -func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) { - selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) - entries := u.availableRemoteCredentialEntries() - for _, entry := range entries { - if entry.ID == selectedID { - return entry, true - } - } - if selectedID == "" && len(entries) == 1 { - return entries[0], true - } - return vault.Entry{}, false -} - -func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { - profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) - entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) - if profileID != "" && entryID != "" { - return appstate.RemoteBinding{ - LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), - RemoteProfileID: profileID, - CredentialEntryID: entryID, - SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), - }, true - } - profile, ok := u.selectedVaultRemoteProfile() - if !ok { - return appstate.RemoteBinding{}, false - } - entry, ok := u.selectedVaultRemoteCredentialEntry() - if !ok { - return appstate.RemoteBinding{}, false - } - return appstate.RemoteBinding{ - 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() - u.selectedVaultRemoteProfileID = u.validRemoteProfileSelection(profiles) - u.selectedVaultRemoteCredentialEntryID = u.validRemoteCredentialSelection(entries) - u.applySelectedRemoteProfileFields() - u.syncRecentRemoteBindingSelection() - u.syncSelectedRemoteBindingMode() -} - -func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { - path = strings.TrimSpace(path) - if path == "" { - return recentRemoteRecord{}, false - } - return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path) -} - -func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool { - _, ok := boundRecentRemoteForLocalVaultRecords(records, strings.TrimSpace(path)) - return ok -} - -func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) { - var matches []recentRemoteRecord - for _, record := range records { - if strings.TrimSpace(record.LocalVaultPath) != path { - continue - } - if strings.TrimSpace(record.RemoteProfileID) == "" || strings.TrimSpace(record.CredentialEntryID) == "" { - continue - } - matches = append(matches, record) - } - if len(matches) != 1 { - return recentRemoteRecord{}, false - } - return matches[0], true -} - -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) - if len(groups) != 1 { - return "" - } - return groups[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." -} - -func (u *ui) bannerSurface() uiBanner { - switch { - case strings.TrimSpace(u.loadingMessage) != "": - return uiBanner{ - Kind: bannerLoading, - Message: strings.TrimSpace(u.loadingMessage), - Detail: u.loadingDetailMessage(), - } - case strings.TrimSpace(u.state.ErrorMessage) != "": - return uiBanner{ - Kind: bannerError, - Message: strings.TrimSpace(u.state.ErrorMessage), - Dismissable: true, - } - default: - return uiBanner{} - } -} - -func (u *ui) statusToastSurface() uiBanner { - if strings.TrimSpace(u.state.StatusMessage) == "" { - return uiBanner{} - } - if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return uiBanner{} - } - return uiBanner{ - Kind: bannerStatus, - Message: strings.TrimSpace(u.state.StatusMessage), - } -} - -func (u *ui) autofillStatusSurface() uiAutofillStatus { - if u.autofillNoticePreference == autofillNoticeSuppressed { - return uiAutofillStatus{} - } - if request, ok := u.pendingAutofillApproval(); ok { - detail := approvalResourceText(request) - if strings.TrimSpace(detail) == "" { - detail = "Review the request to allow or deny this fill attempt." - } - return uiAutofillStatus{ - Kind: autofillStatusAwaitingApproval, - Title: "Autofill approval needed", - Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.", - Detail: detail, - } - } - if u.auditLog == nil { - return uiAutofillStatus{} - } - if u.autofillNoticePreference == autofillNoticeApprovals { - return uiAutofillStatus{} - } - for _, event := range u.auditLog.Events() { - if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { - return status - } - } - return uiAutofillStatus{} -} - -func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) { - for _, request := range u.state.PendingApprovals() { - if isAutofillOperation(request.Operation) { - return request, true - } - } - return apiapproval.Request{}, false -} - -func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) { - if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL { - return uiAutofillStatus{}, false - } - - requester := formatAutofillRequester(event.ClientName, event.TokenName) - switch event.Type { - case apiaudit.EventAutofillFound: - return uiAutofillStatus{ - Kind: autofillStatusFound, - Title: "Autofill match ready", - Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventAutofillAmbiguous: - return uiAutofillStatus{ - Kind: autofillStatusAmbiguous, - Title: "Autofill needs a narrower match", - Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventAutofillBlocked: - return uiAutofillStatus{ - Kind: autofillStatusBlocked, - Title: "Autofill is blocked", - Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventApprovalAllowed: - if !isAutofillOperation(event.Operation) { - return uiAutofillStatus{}, false - } - return uiAutofillStatus{ - Kind: autofillStatusFound, - Title: "Autofill approved", - Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut: - if !isAutofillOperation(event.Operation) { - return uiAutofillStatus{}, false - } - return uiAutofillStatus{ - Kind: autofillStatusBlocked, - Title: "Autofill was not allowed", - Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)), - Detail: autofillEventDetail(event), - }, true - default: - return uiAutofillStatus{}, false - } -} - -func autofillEventDetail(event apiaudit.Event) string { - return strings.TrimSpace(resourceDetailText(event.Resource)) -} - -func resourceDetailText(resource apitokens.Resource) string { - switch resource.Kind { - case apitokens.ResourceEntry: - if entryID := strings.TrimSpace(resource.EntryID); entryID != "" { - return "Entry ID: " + entryID - } - case apitokens.ResourceGroup: - if len(resource.Path) > 0 { - return "Group: " + strings.Join(resource.Path, " / ") - } - } - return "" -} - -func formatAutofillRequester(clientName, tokenName string) string { - switch { - case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "": - return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")" - case strings.TrimSpace(clientName) != "": - return strings.TrimSpace(clientName) - case strings.TrimSpace(tokenName) != "": - return strings.TrimSpace(tokenName) - default: - return "A trusted client" - } -} - -func defaultAutofillMessage(value, fallback string) string { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - return fallback -} - -func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string { - switch eventType { - case apiaudit.EventApprovalDenied: - return requester + " was denied for this fill request." - case apiaudit.EventApprovalCanceled: - return requester + " canceled this fill request." - case apiaudit.EventApprovalTimedOut: - return requester + " timed out while waiting for approval." - default: - return requester + " could not fill this target." - } -} - -func isAutofillOperation(operation apitokens.Operation) bool { - switch operation { - case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL: - return true - default: - return false - } -} - -func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { - if !u.shouldShowLifecycleSetup() { - if banner.Dismissable { - return "", "Dismiss" - } - return "", "" - } - switch banner.Kind { - case bannerLoading: - if strings.HasPrefix(u.loadingActionLabel, "open ") { - return "Cancel", "" - } - case bannerError: - if u.canRetryLifecycleOpen() { - return "Retry", "Dismiss" - } - if banner.Dismissable { - return "", "Dismiss" - } - } - return "", "" -} - -func (u *ui) loadingDetailMessage() string { - if !u.shouldShowLifecycleSetup() { - return "" - } - if u.lifecycleMode == "remote" { - baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) - path := strings.TrimSpace(u.remotePath.Text()) - switch { - case baseURL != "" && path != "": - return fmt.Sprintf( - "Target: %s (%s)", - friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), - path, - ) - case baseURL != "": - return "Target: " + baseURL - default: - return "Preparing remote vault access" - } - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return "Preparing local vault access" - } - return "Target: " + path -} - -func (u *ui) currentVaultSummary() vaultSummary { - status, ok := u.state.Session.(sessionStatus) - if !ok || !status.HasVault() { - return vaultSummary{} - } - if status.IsRemote() { - baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) - path := strings.TrimSpace(u.remotePath.Text()) - summary := vaultSummary{ - Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), - Detail: baseURL, - } - if strings.TrimSpace(summary.Title) == "" { - summary.Title = "Remote vault" - } - summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path)) - return summary - } - path := strings.TrimSpace(u.vaultPath.Text()) - summary := vaultSummary{ - Title: friendlyRecentVaultLabel(path), - Detail: path, - } - if strings.TrimSpace(summary.Title) == "" { - summary.Title = "Local vault" - } - summary.Context = u.vaultResumeContext(u.recentVaultGroup(path)) - return summary -} - -func (u *ui) vaultResumeContext(path []string) string { - if len(path) == 0 { - return "" - } - displayPath := append([]string(nil), path...) - if len(displayPath) == 0 { - return "" - } - return "Resume in: " + strings.Join(displayPath, " / ") -} - -func compactPathDirectorySummary(path string) string { - cleaned := filepath.Clean(strings.TrimSpace(path)) - if cleaned == "." || cleaned == "" { - return "" - } - dir := filepath.Dir(cleaned) - if dir == "." || dir == cleaned { - return "" - } - if dir == string(filepath.Separator) { - return dir - } - parts := strings.Split(filepath.ToSlash(dir), "/") - filtered := parts[:0] - for _, part := range parts { - if strings.TrimSpace(part) != "" { - filtered = append(filtered, part) - } - } - parts = filtered - if len(parts) <= 2 { - return filepath.ToSlash(dir) - } - return parts[0] + "/.../" + parts[len(parts)-1] -} - -func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { - if !u.requestMasterPassFocus { - return - } - gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) - gtx.Execute(op.InvalidateCmd{}) - u.requestMasterPassFocus = false -} - -func (u *ui) sessionSurface() uiSurface { - if u.state.Session == nil { - return uiSurface{} - } - if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { - return uiSurface{ - Title: "Vault locked", - Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", - Locked: true, - } - } - return uiSurface{} -} - -func (u *ui) hasOpenVault() bool { - status, ok := u.state.Session.(sessionStatus) - if ok { - return status.HasVault() - } - _, err := u.state.Session.Current() - return err == nil -} - -func (u *ui) isVaultLocked() bool { - status, ok := u.state.Session.(sessionStatus) - if ok { - return status.IsLocked() - } - _, err := u.state.Session.Current() - return errors.Is(err, session.ErrLocked) -} - -func (u *ui) shouldShowLifecycleSetup() bool { - return !u.hasOpenVault() -} - -func (u *ui) lifecycleBusy() bool { - return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" -} - -func (u *ui) updateViewportLayoutMode(gtx layout.Context) { - u.viewportMeasured = true - u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) -} - -func (u *ui) usesCompactViewport() bool { - if u.viewportMeasured { - return u.compactViewport - } - return u.mode == "phone" -} - -func (u *ui) shouldUseLockedSinglePane() bool { - return u.isVaultLocked() && !u.shouldShowLifecycleSetup() -} - -func (u *ui) shouldShowDesktopWorkingHeader() bool { - return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() -} - -func (u *ui) shouldUseCompactPhoneDetailPane() bool { - if !u.usesCompactViewport() { - return false - } - if u.isVaultLocked() || u.editingEntry { - return false - } - _, ok := u.selectedEntry() - return !ok -} - -func (u *ui) chooseExistingFileAction(target *widget.Editor) error { - path, err := pickExistingFile() - if err != nil { - return err - } - target.SetText(path) - return nil -} - -func (u *ui) listEmptyMessage() string { - return u.listEmptyState().Body -} - -func (u *ui) listEmptyState() emptyState { - if surface := u.sessionSurface(); surface.Locked { - return emptyState{ - Title: "Vault locked", - Body: "Unlock the vault to browse entries and groups.", - } - } - query := strings.TrimSpace(u.search.Text()) - if query != "" { - switch u.state.Section { - case appstate.SectionAPITokens: - return emptyState{ - Title: "No matching API tokens", - Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query), - } - case appstate.SectionAPIAudit: - return emptyState{ - Title: "No matching audit events", - Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query), - } - case appstate.SectionTemplates: - return emptyState{ - Title: "No matching templates", - Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), - } - case appstate.SectionRecycleBin: - return emptyState{ - Title: "No matching deleted entries", - Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), - } - default: - return emptyState{ - Title: "No matching entries", - Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), - } - } - } - switch u.state.Section { - case appstate.SectionAPITokens: - return emptyState{ - Title: "No API tokens yet", - Body: "Issue a token to grant scoped gRPC access to an external tool.", - } - case appstate.SectionAPIAudit: - return emptyState{ - Title: "No API audit events yet", - Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", - } - case appstate.SectionAbout: - return emptyState{ - Title: "About KeePassGO", - Body: "Product details, compatibility notes, and platform targets appear in the detail pane.", - } - case appstate.SectionTemplates: - return emptyState{ - Title: "Templates unavailable", - Body: "Templates are not available in this build.", - } - case appstate.SectionRecycleBin: - return emptyState{ - Title: "Recycle Bin is empty", - Body: "Deleted entries will appear here until restored.", - } - default: - if len(u.displayPath()) > 0 { - return emptyState{ - Title: "This group is empty", - Body: "Add an entry here, search below this point, or open a subgroup.", - } - } - return emptyState{ - Title: "No entries yet", - Body: "Create or open a vault, then add an entry to get started.", - } - } -} - -func (u *ui) detailPlaceholderMessage() string { - if surface := u.sessionSurface(); surface.Locked { - return "Unlock the vault to inspect entries, attachments, and history." - } - if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || - strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || - strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { - return "Complete the form to create a new item or update the current selection." - } - switch u.state.Section { - case appstate.SectionAPITokens: - return "Select an API token, issue a new one, or search to narrow the list." - case appstate.SectionAPIAudit: - return "Select an audit event to inspect it, or use Search audit log or the quick filters above." - case appstate.SectionAbout: - return "Review the product overview, platform support, and compatibility goals." - case appstate.SectionTemplates: - return "Select a template or start a reusable entry." - case appstate.SectionRecycleBin: - return "Select a deleted entry to review or restore it." - default: - if strings.TrimSpace(u.search.Text()) != "" { - return "Select a matching entry from the filtered list or clear the search." - } - if len(u.displayPath()) == 0 { - return "Select an entry from the vault root or open a group." - } - return "Select an entry or start a new one." - } -} - -func (u *ui) ensureNavClickables() { - u.syncCurrentPath() - if len(u.breadcrumbs) < len(u.currentPath)+1 { - u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) - } -} - -func (u *ui) syncPhoneGroupBrowser(path []string) { - if !u.usesCompactViewport() { - return - } - u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 -} - -func (u *ui) setCurrentPath(path []string) { - u.currentPath = append([]string(nil), path...) - u.state.NavigateToPath(path) - u.syncedPath = append([]string(nil), path...) - u.syncPhoneGroupBrowser(path) - u.noteCurrentVaultPath() - u.clearDeleteGroupConfirmation() -} - -func (u *ui) syncCurrentPath() { - switch { - case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): - u.currentPath = append([]string(nil), u.state.CurrentPath...) - case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): - u.state.CurrentPath = append([]string(nil), u.currentPath...) - case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): - u.state.CurrentPath = append([]string(nil), u.currentPath...) - } - u.syncedPath = append([]string(nil), u.currentPath...) - u.noteCurrentVaultPath() - if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) { - u.clearDeleteGroupConfirmation() - } -} - -func (u *ui) noteCurrentVaultPath() { - status, ok := u.state.Session.(sessionStatus) - if !ok || status.IsLocked() { - return - } - if status.IsRemote() { - u.noteCurrentRemotePath() - return - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return - } - if u.recentVaultGroups == nil { - u.recentVaultGroups = map[string][]string{} - } - u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) - u.saveRecentVaults() -} - -func (u *ui) layout(gtx layout.Context) layout.Dimensions { - // Clear the full frame explicitly so mobile surfaces don't start from an - // unpainted black buffer before nested background widgets run. - paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) - u.phoneSyncMenuVisible = false - u.phoneMainMenuVisible = false - u.syncHostedAPI() - u.filter() - u.processShortcuts(gtx) - u.handleLifecycleClicks(gtx) - u.handleHeaderAndDialogClicks(gtx) - u.handleSettingsClicks(gtx) - u.handleSectionAndSyncClicks(gtx) - u.handleApprovalAndAPIClicks(gtx) - u.handleSelectionClicks(gtx) - u.handleVaultAndEntryClicks(gtx) - u.handleGroupClicks(gtx) - u.handleInputUpdates(gtx) - u.updateViewportLayoutMode(gtx) - inset := layout.UniformInset(unit.Dp(16)) - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { - return inset.Layout(gtx, u.mainFrame) - }) - }), - layout.Stacked(u.syncDialogOverlay), - layout.Stacked(u.securityDialogOverlay), - layout.Stacked(u.remotePrefsDialogOverlay), - layout.Stacked(u.approvalDialogOverlay), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - return u.phoneHeaderMenus(gtx) - }), - layout.Stacked(u.statusToast), - ) -} - -func (u *ui) handleLifecycleClicks(gtx layout.Context) { - for u.createVault.Clicked(gtx) { - u.runAction("create vault", u.createVaultAction) - } - for u.openVault.Clicked(gtx) { - u.startOpenVaultAction() - } - for u.lifecycleRemoteSyncAction.Clicked(gtx) { - if !u.lifecycleBusy() { - u.beginLifecycleRemoteSyncOpen() - } - } - for u.unlockVault.Clicked(gtx) { - u.startUnlockAction() - } - for u.cancelLifecycleProgress.Clicked(gtx) { - u.cancelLifecycleBusyState() - } - for u.retryLifecycleOpen.Clicked(gtx) { - u.state.ErrorMessage = "" - u.retryLastLifecycleOpen() - } - for u.toggleLifecycleAdvanced.Clicked(gtx) { - if !u.lifecycleBusy() { - u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden - u.saveUIPreferences() - } - } -} - -func (u *ui) handleHeaderAndDialogClicks(gtx layout.Context) { - u.handleHeaderActionClicks(gtx) - u.handleDialogControlClicks(gtx) - u.handleBannerClicks(gtx) -} - -func (u *ui) handleHeaderActionClicks(gtx layout.Context) { - for u.saveVault.Clicked(gtx) { - u.runAction("save vault", u.saveAction) - } - for u.saveAsVault.Clicked(gtx) { - u.runAction("save-as vault", u.saveAsAction) - } - for u.openRemote.Clicked(gtx) { - u.startOpenRemoteAction() - } - for u.changeMasterKey.Clicked(gtx) { - u.runAction("change master key", u.changeMasterKeyAction) - } - for u.synchronizeVault.Clicked(gtx) { - u.runAction("synchronize vault", u.synchronizeAction) - } - for u.toggleSyncMenu.Clicked(gtx) { - u.syncMenuOpen = !u.syncMenuOpen - if u.syncMenuOpen { - u.mainMenuOpen = false - } - } - for u.toggleMainMenu.Clicked(gtx) { - u.mainMenuOpen = !u.mainMenuOpen - if u.mainMenuOpen { - u.syncMenuOpen = false - } - } - for u.openAdvancedSync.Clicked(gtx) { - u.openAdvancedSyncDialog() - } - for u.openSecuritySettings.Clicked(gtx) { - u.loadSecuritySettingsFromSession() - u.loadSettingsFormFromPreferences() - u.loadSettingsDraft() - u.mainMenuOpen = false - u.securityDialogOpen = true - } - for u.openRemotePrefsHelp.Clicked(gtx) { - u.remotePrefsDialogOpen = true - } - for u.lockVault.Clicked(gtx) { - u.runAction("lock vault", u.lockAction) - } -} - -func (u *ui) handleDialogControlClicks(gtx layout.Context) { - for u.closeAdvancedSync.Clicked(gtx) { - u.syncDialogOpen = false - u.showSyncPassword = false - } - for u.closeSecuritySettings.Clicked(gtx) { - u.securityDialogOpen = false - } - for u.closeRemotePrefsHelp.Clicked(gtx) { - u.remotePrefsDialogOpen = false - } - for u.runAdvancedSync.Clicked(gtx) { - u.runAction("advanced synchronize vault", u.advancedSyncAction) - } - for u.saveSecuritySettings.Clicked(gtx) { - u.runAction("save settings", u.saveSecuritySettingsAction) - } -} - -func (u *ui) handleBannerClicks(gtx layout.Context) { - for u.dismissBanner.Clicked(gtx) { - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - } -} - -func (u *ui) handleSettingsClicks(gtx layout.Context) { - u.handleStatusPreferenceClicks(gtx) - u.handleAutofillPreferenceClicks(gtx) - u.handleAccessibilityClicks(gtx) - u.handleSettingsSyncDefaultClicks(gtx) -} - -func (u *ui) handleStatusPreferenceClicks(gtx layout.Context) { - for u.setStatusBannerShort.Clicked(gtx) { - u.setStatusBannerTTL(2 * time.Second) - } - for u.setStatusBannerStandard.Clicked(gtx) { - u.setStatusBannerTTL(statusBannerDuration) - } - for u.setStatusBannerLong.Clicked(gtx) { - u.setStatusBannerTTL(statusBannerLong) - } -} - -func (u *ui) handleAutofillPreferenceClicks(gtx layout.Context) { - for u.showAllAutofillNotices.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeAll) - } - for u.showApprovalAutofillOnly.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeApprovals) - } - for u.hideAutofillNotices.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeSuppressed) - } - for u.showAutofillApprovalAsk.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk - u.saveUIPreferences() - } - for u.showAutofillApprovalAllow.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow - u.saveUIPreferences() - } - for u.showAutofillApprovalBlock.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock - u.saveUIPreferences() - } -} - -func (u *ui) handleAccessibilityClicks(gtx layout.Context) { - for u.settingsDensityDense.Clicked(gtx) { - u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense - _ = u.applySecuritySettingsLive() - } - for u.settingsDensityComfortable.Clicked(gtx) { - u.settingsDraft.Accessibility.DisplayDensity = displayDensityComfortable - _ = u.applySecuritySettingsLive() - } - for u.settingsContrastStandard.Clicked(gtx) { - u.settingsDraft.Accessibility.Contrast = contrastStandard - _ = u.applySecuritySettingsLive() - } - for u.settingsContrastHigh.Clicked(gtx) { - u.settingsDraft.Accessibility.Contrast = contrastHigh - _ = u.applySecuritySettingsLive() - } - for u.settingsReducedMotionOff.Clicked(gtx) { - u.settingsDraft.Accessibility.ReducedMotion = false - _ = u.applySecuritySettingsLive() - } - for u.settingsReducedMotionOn.Clicked(gtx) { - u.settingsDraft.Accessibility.ReducedMotion = true - _ = u.applySecuritySettingsLive() - } - for u.settingsKeyboardFocusStandard.Clicked(gtx) { - u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusStandard - _ = u.applySecuritySettingsLive() - } - for u.settingsKeyboardFocusProminent.Clicked(gtx) { - u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent - _ = u.applySecuritySettingsLive() - } -} - -func (u *ui) handleSettingsSyncDefaultClicks(gtx layout.Context) { - for u.showSettingsSyncLocal.Clicked(gtx) { - u.settingsDraft.Sync.SourceDefault = syncSourceLocal - _ = u.applySecuritySettingsLive() - } - for u.showSettingsSyncRemote.Clicked(gtx) { - u.settingsDraft.Sync.SourceDefault = syncSourceRemote - _ = u.applySecuritySettingsLive() - } - for u.showSettingsSyncPull.Clicked(gtx) { - u.settingsDraft.Sync.DirectionDefault = syncDirectionPull - _ = u.applySecuritySettingsLive() - } - for u.showSettingsSyncPush.Clicked(gtx) { - u.settingsDraft.Sync.DirectionDefault = syncDirectionPush - _ = u.applySecuritySettingsLive() - } -} - -func (u *ui) handleSectionAndSyncClicks(gtx layout.Context) { - u.handleSectionClicks(gtx) - u.handleLifecycleModeClicks(gtx) - u.handleSyncChoiceClicks(gtx) - u.handleRemoteBindingClicks(gtx) -} - -func (u *ui) handleSectionClicks(gtx layout.Context) { - for u.showEntries.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showEntriesSection() - } - for u.showTemplates.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showTemplatesSection() - } - for u.showRecycle.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showRecycleBinSection() - } - for u.showAPITokens.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showAPITokensSection() - } - for u.showAPIAudit.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showAPIAuditSection() - } - for u.showAbout.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showAboutSection() - } -} - -func (u *ui) handleLifecycleModeClicks(gtx layout.Context) { - for u.showLocalLifecycle.Clicked(gtx) { - if !u.lifecycleBusy() { - u.lifecycleMode = "local" - u.requestMasterPassFocus = true - } - } - for u.showRemoteLifecycle.Clicked(gtx) { - if !u.lifecycleBusy() { - u.lifecycleMode = "remote" - u.selectedRemoteConnection = false - u.requestMasterPassFocus = true - } - } -} - -func (u *ui) handleSyncChoiceClicks(gtx layout.Context) { - for u.showSyncLocal.Clicked(gtx) { - u.syncSourceMode = syncSourceLocal - } - for u.showSyncRemote.Clicked(gtx) { - u.syncSourceMode = syncSourceRemote - } - for u.showSyncPull.Clicked(gtx) { - u.syncDirection = syncDirectionPull - } - for u.showSyncPush.Clicked(gtx) { - u.syncDirection = syncDirectionPush - } -} - -func (u *ui) handleRemoteBindingClicks(gtx layout.Context) { - for u.useSavedAdvancedSyncRemote.Clicked(gtx) { - u.openRemoteSyncSetupDialog() - } - for u.openSelectedVaultRemote.Clicked(gtx) { - if !u.lifecycleBusy() { - u.startOpenRemoteAction() - } - } - for u.saveCurrentRemoteBinding.Clicked(gtx) { - u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) - } - for u.removeSelectedRemoteBinding.Clicked(gtx) { - u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction) - } - for u.shareCurrentVault.Clicked(gtx) { - u.runAction("share vault", u.shareCurrentVaultAction) - } -} - -func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { - u.handleApprovalClicks(gtx) - u.handleAPITokenClicks(gtx) - u.handleAPIPolicyClicks(gtx) -} - -func (u *ui) handleApprovalClicks(gtx layout.Context) { - for u.allowApproval.Clicked(gtx) { - u.runAction("allow API request", func() error { - outcome := apiapproval.OutcomeAllowOnce - if u.approvalPermanent.Value { - outcome = apiapproval.OutcomeAllowPermanent - } - err := u.resolvePendingApproval(outcome) - u.approvalPermanent.Value = false - return err - }) - } - for u.denyApproval.Clicked(gtx) { - u.runAction("deny API request", func() error { - outcome := apiapproval.OutcomeDenyOnce - if u.approvalPermanent.Value { - outcome = apiapproval.OutcomeDenyPermanent - } - err := u.resolvePendingApproval(outcome) - u.approvalPermanent.Value = false - return err - }) - } - for u.cancelApproval.Clicked(gtx) { - u.runAction("cancel API request", func() error { - err := u.resolvePendingApproval(apiapproval.OutcomeCancel) - u.approvalPermanent.Value = false - return err - }) - } -} - -func (u *ui) handleAPITokenClicks(gtx layout.Context) { - for u.issueAPIToken.Clicked(gtx) { - u.runAction("issue API token", u.issueAPITokenAction) - } - for u.saveAPIToken.Clicked(gtx) { - u.runAction("save API token", u.saveAPITokenAction) - } - for u.rotateAPIToken.Clicked(gtx) { - u.runAction("rotate API token", u.rotateAPITokenAction) - } - for u.disableAPIToken.Clicked(gtx) { - u.runAction("disable API token", u.disableAPITokenAction) - } - for u.revokeAPIToken.Clicked(gtx) { - u.runAction("revoke API token", u.revokeAPITokenAction) - } - for u.deleteAPIToken.Clicked(gtx) { - u.runAction("delete API token", u.deleteAPITokenAction) - } - for u.copyAPITokenSecret.Clicked(gtx) { - secret := u.apiTokenSecret - u.runAction("copy API token secret", func() error { - if strings.TrimSpace(secret) == "" { - return fmt.Errorf("no API token secret to copy") - } - if u.clipboardWriter != nil { - return u.clipboardWriter.WriteText(secret) - } - return clipboard.WriteText(secret) - }) - } -} - -func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { - for u.addAPIPolicyRule.Clicked(gtx) { - u.runAction("add API policy rule", u.addAPIPolicyRuleAction) - } - for u.useCurrentGroupForPolicy.Clicked(gtx) { - u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction) - } - for u.useSelectedEntryForPolicy.Clicked(gtx) { - u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction) - } - for u.clearAPIPolicyTarget.Clicked(gtx) { - u.runAction("clear API policy target", u.clearAPIPolicyTargetAction) - } - for i := range u.apiPolicyRemoves { - for u.apiPolicyRemoves[i].Clicked(gtx) { - index := i - u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) - } - } -} - -func (u *ui) handleSelectionClicks(gtx layout.Context) { - u.handleFileSelectionClicks(gtx) - u.handleRecentSelectionClicks(gtx) - u.handleRemoteSelectionClicks(gtx) - u.handleClearSelectionClicks(gtx) -} - -func (u *ui) handleFileSelectionClicks(gtx layout.Context) { - for u.pickVaultPath.Clicked(gtx) { - if !u.lifecycleBusy() { - u.startChooseVaultPathAction() - } - } - for u.importSharedVault.Clicked(gtx) { - if !u.lifecycleBusy() { - u.startImportSharedVaultAction() - } - } - for u.pickKeyFile.Clicked(gtx) { - if !u.lifecycleBusy() { - u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) - } - } - for u.pickSyncLocalPath.Clicked(gtx) { - u.startChooseSyncLocalSourceAction() - } -} - -func (u *ui) handleRecentSelectionClicks(gtx layout.Context) { - for i := range u.recentVaultClicks { - for u.recentVaultClicks[i].Clicked(gtx) { - if !u.lifecycleBusy() && i < len(u.recentVaults) { - u.lifecycleMode = "local" - u.vaultPath.SetText(u.recentVaults[i]) - u.requestMasterPassFocus = true - } - } - } - for i := range u.recentRemoteClicks { - for u.recentRemoteClicks[i].Clicked(gtx) { - if !u.lifecycleBusy() && i < len(u.recentRemotes) { - u.lifecycleMode = "remote" - u.applyRecentRemoteRecord(u.recentRemotes[i]) - u.requestMasterPassFocus = true - } - } - } -} - -func (u *ui) handleRemoteSelectionClicks(gtx layout.Context) { - for i := range u.vaultRemoteProfileClicks { - for u.vaultRemoteProfileClicks[i].Clicked(gtx) { - profiles := u.availableRemoteProfiles() - if i < len(profiles) { - u.selectVaultRemoteProfile(profiles[i].ID) - } - } - } - for i := range u.vaultRemoteCredentialClicks { - for u.vaultRemoteCredentialClicks[i].Clicked(gtx) { - entries := u.availableRemoteCredentialEntries() - if i < len(entries) { - u.selectVaultRemoteCredentialEntry(entries[i].ID) - } - } - } - for i := range u.syncRemoteCredentialClicks { - for u.syncRemoteCredentialClicks[i].Clicked(gtx) { - entries := u.matchingAdvancedSyncRemoteCredentialEntries() - if i < len(entries) { - u.applyAdvancedSyncRemoteCredentialEntry(entries[i]) - } - } - } -} - -func (u *ui) handleClearSelectionClicks(gtx layout.Context) { - for u.clearVaultSelection.Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - if u.shouldUseLockedSinglePane() { - u.switchToLifecycleSelection("local") - continue - } - u.vaultPath.SetText("") - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.requestMasterPassFocus = true - } - for u.clearRemoteSelection.Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - if u.shouldUseLockedSinglePane() { - u.switchToLifecycleSelection("remote") - continue - } - u.selectedRemoteConnection = false - u.remoteBaseURL.SetText("") - u.remotePath.SetText("") - u.remoteUsername.SetText("") - u.remotePassword.SetText("") - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.requestMasterPassFocus = true - } -} - -func (u *ui) handleVaultAndEntryClicks(gtx layout.Context) { - u.handleEntryEditorClicks(gtx) - u.handleEntryMutationClicks(gtx) - u.handleAttachmentAndCopyClicks(gtx) -} - -func (u *ui) handleEntryEditorClicks(gtx layout.Context) { - for u.editEntry.Clicked(gtx) { - u.editingEntry = true - u.loadSelectedEntryIntoEditor() - } - for u.cancelEdit.Clicked(gtx) { - u.editingEntry = false - u.loadSelectedEntryIntoEditor() - } - for u.addEntry.Clicked(gtx) { - u.state.BeginNewEntry() - u.loadSelectedEntryIntoEditor() - u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) - u.editingEntry = true - } -} - -func (u *ui) handleEntryMutationClicks(gtx layout.Context) { - for u.saveEntry.Clicked(gtx) { - u.runAction("save entry", u.saveEntryAction) - } - for u.duplicateEntry.Clicked(gtx) { - u.runAction("duplicate entry", u.duplicateSelectedEntryAction) - } - for u.deleteEntry.Clicked(gtx) { - u.runAction("delete entry", u.deleteSelectedEntryAction) - } - for u.restoreEntry.Clicked(gtx) { - u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) - } - for u.saveTemplate.Clicked(gtx) { - u.runAction("save template", u.saveTemplateAction) - } - for u.deleteTemplate.Clicked(gtx) { - u.runAction("delete template", u.deleteSelectedTemplateAction) - } - for u.instantiateTemplate.Clicked(gtx) { - u.runAction("instantiate template", u.instantiateSelectedTemplateAction) - } -} - -func (u *ui) handleAttachmentAndCopyClicks(gtx layout.Context) { - for u.addAttachment.Clicked(gtx) { - u.runAction("add attachment", u.addAttachmentAction) - } - for u.replaceAttachment.Clicked(gtx) { - u.runAction("replace attachment", u.replaceAttachmentAction) - } - for u.removeAttachment.Clicked(gtx) { - u.runAction("remove attachment", u.removeAttachmentAction) - } - for u.exportAttachment.Clicked(gtx) { - u.runAction("export attachment", u.exportAttachmentAction) - } - for u.copyUser.Clicked(gtx) { - u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) - } - for u.copyPass.Clicked(gtx) { - u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) - } - for u.copyURL.Clicked(gtx) { - u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) - } - for u.generatePassword.Clicked(gtx) { - u.runAction("generate password", u.generatePasswordAction) - } - for u.restoreHistory.Clicked(gtx) { - u.runAction("restore history", u.restoreSelectedHistoryAction) - } -} - -func (u *ui) handleGroupClicks(gtx layout.Context) { - for u.createGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.runAction("create group", u.createGroupAction) - } - for u.moveGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.runAction("move group", u.moveCurrentGroupAction) - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.syncedPath = append([]string(nil), u.state.CurrentPath...) - u.filter() - } - for u.toggleGroupControls.Clicked(gtx) { - u.groupControlsHidden = !u.groupControlsHidden - u.saveUIPreferences() - } - for u.toggleHistory.Clicked(gtx) { - u.historyHidden = !u.historyHidden - u.saveUIPreferences() - } - for u.renameGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.runAction("rename group", u.renameGroupAction) - } - for u.deleteGroup.Clicked(gtx) { - u.armDeleteCurrentGroupAction() - } - for u.confirmDeleteGroup.Clicked(gtx) { - u.runAction("delete group", u.deleteCurrentGroupAction) - u.clearDeleteGroupConfirmation() - } - for u.cancelDeleteGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - } -} - -func (u *ui) handleInputUpdates(gtx layout.Context) { - if u.securityDialogOpen { - if _, changed := u.securityCipher.Update(gtx); changed { - _ = u.applySecuritySettingsLive() - } - if _, changed := u.securityKDF.Update(gtx); changed { - _ = u.applySecuritySettingsLive() - } - if _, changed := u.autofillBrowserAllowlist.Update(gtx); changed { - u.saveUIPreferences() - } - if _, changed := u.autofillAppAllowlist.Update(gtx); changed { - u.saveUIPreferences() - } - if _, changed := u.autofillPackageRules.Update(gtx); changed { - u.saveUIPreferences() - } - } - for u.togglePassword.Clicked(gtx) { - u.showPassword = !u.showPassword - } - for u.togglePasswordInline.Clicked(gtx) { - u.showPassword = !u.showPassword - } - for u.toggleSyncPassword.Clicked(gtx) { - u.showSyncPassword = !u.showSyncPassword - if u.showSyncPassword { - u.syncRemotePassword.Mask = 0 - } else { - u.syncRemotePassword.Mask = '•' - } - } - if _, changed := u.search.Update(gtx); changed { - u.filter() - } -} - -func (u *ui) mainFrame(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.header), - layout.Rigid(u.bannerRow), - layout.Rigid(u.autofillStatusRow), - layout.Flexed(1, u.primaryContent), - ) -} - -func (u *ui) bannerRow(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind == bannerNone { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(u.banner), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - ) -} - -func (u *ui) autofillStatusRow(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind != bannerNone || u.autofillStatusSurface().Kind == autofillStatusNone { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(u.autofillStatusCard), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - ) -} - -func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { - switch { - case u.shouldShowLifecycleSetup(): - return u.lifecycleScreen(gtx) - case u.shouldUseLockedSinglePane(): - return u.detailPanel(gtx) - case u.usesCompactViewport(): - return u.compactPrimaryContent(gtx) - default: - return u.widePrimaryContent(gtx) - } -} - -func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { - u.phoneSpan = gtx.Constraints.Max.Y - listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) - if min := gtx.Dp(unit.Dp(180)); listHeight < min { - listHeight = min - } - if max := gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)); listHeight > max { - listHeight = max - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.Y = listHeight - gtx.Constraints.Max.Y = listHeight - return u.listPanel(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(u.phoneSlider), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - u.compactDetailFlexChild(), - ) -} - -func (u *ui) compactDetailFlexChild() layout.FlexChild { - if u.shouldUseCompactPhoneDetailPane() { - return layout.Rigid(u.detailPanel) - } - return layout.Flexed(1, u.detailPanel) -} - -func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(0.38, u.listPanel), - layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), - layout.Flexed(0.62, u.detailPanel), - ) -} - -func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { - if !u.syncDialogOpen { - return layout.Dimensions{} - } - return u.syncDialog(gtx) -} - -func (u *ui) securityDialogOverlay(gtx layout.Context) layout.Dimensions { - if !u.securityDialogOpen { - return layout.Dimensions{} - } - return u.securityDialog(gtx) -} - -func (u *ui) remotePrefsDialogOverlay(gtx layout.Context) layout.Dimensions { - if !u.remotePrefsDialogOpen { - return layout.Dimensions{} - } - return u.remotePrefsDialog(gtx) -} - -func (u *ui) approvalDialogOverlay(gtx layout.Context) layout.Dimensions { - if _, ok := u.pendingApproval(); !ok { - return layout.Dimensions{} - } - return u.approvalDialog(gtx) -} - func (u *ui) syncHostedAPI() { if u.apiHost == nil { return @@ -5514,22 +1612,22 @@ func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, uni return inset, titleSize, metaSize, urlSize, pathSize, dividerGap } -func (u *ui) listPanelTopSections() []listPanelTopSection { - sections := make([]listPanelTopSection, 0, 6) +func (u *ui) listPanelTopSections() []listlayout.TopSection { + sections := make([]listlayout.TopSection, 0, 6) if u.state.Section != appstate.SectionAbout { - sections = append(sections, listPanelTopSearch) + sections = append(sections, listlayout.TopSearch) } if !u.isVaultLocked() { - sections = append(sections, listPanelTopNavigation) + sections = append(sections, listlayout.TopNavigation) } if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { - sections = append(sections, listPanelTopPath) + sections = append(sections, listlayout.TopPath) } if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries { - sections = append(sections, listPanelTopGroup, listPanelTopGroupTools) + sections = append(sections, listlayout.TopGroup, listlayout.TopGroupTools) } if !u.isVaultLocked() { - sections = append(sections, listPanelTopPrimary) + sections = append(sections, listlayout.TopPrimary) } return sections } @@ -5626,19 +1724,19 @@ func (u *ui) compactListPanelTopRows() []layout.Widget { return rows } -func (u *ui) listPanelTopSectionWidget(section listPanelTopSection) layout.Widget { +func (u *ui) listPanelTopSectionWidget(section listlayout.TopSection) layout.Widget { switch section { - case listPanelTopSearch: + case listlayout.TopSearch: return u.listPanelSearchRow - case listPanelTopNavigation: + case listlayout.TopNavigation: return u.navigationHeader - case listPanelTopPath: + case listlayout.TopPath: return u.pathBar - case listPanelTopGroup: + case listlayout.TopGroup: return u.groupBar - case listPanelTopGroupTools: + case listlayout.TopGroupTools: return u.groupControlsSection - case listPanelTopPrimary: + case listlayout.TopPrimary: return u.listPanelPrimaryActionRow default: return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } @@ -5661,39 +1759,39 @@ func (u *ui) wideListPanelChildren() []layout.FlexChild { return children } -func (u *ui) wideListPanelTopSectionWidget(section listPanelTopSection) layout.Widget { +func (u *ui) wideListPanelTopSectionWidget(section listlayout.TopSection) layout.Widget { switch section { - case listPanelTopSearch: + case listlayout.TopSearch: return u.listPanelSearchRow - case listPanelTopNavigation: + case listlayout.TopNavigation: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() { return layout.Dimensions{} } return u.navigationHeader(gtx) } - case listPanelTopPath: + case listlayout.TopPath: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} } return u.pathBar(gtx) } - case listPanelTopGroup: + case listlayout.TopGroup: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupBar(gtx) } - case listPanelTopGroupTools: + case listlayout.TopGroupTools: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsSection(gtx) } - case listPanelTopPrimary: + case listlayout.TopPrimary: return u.listPanelPrimaryActionRow default: return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } @@ -5931,17 +2029,19 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { } func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { + _, hasSelectedEntry := u.selectedEntry() + mode := detaillayout.Resolve(u.isVaultLocked(), u.state.Section == appstate.SectionAPITokens || u.state.Section == appstate.SectionAPIAudit || u.state.Section == appstate.SectionAbout, hasSelectedEntry, u.editingEntry) + switch mode { + case detaillayout.ModeLocked: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.lockedDetailChildren()...) - } - if panel := u.staticDetailPanel(); panel != nil { + case detaillayout.ModeStatic: + panel := u.staticDetailPanel() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...) - } - item, ok := u.selectedEntry() - if !ok && !u.editingEntry { + case detaillayout.ModeEmpty: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.emptyDetailChildren()...) } - if u.editingEntry { + item, ok := u.selectedEntry() + if mode == detaillayout.ModeEditor { return u.detailEditorContent(gtx, ok) } return u.detailViewContent(gtx, item) @@ -7118,173 +3218,3 @@ func fill(c color.NRGBA) layout.Widget { return layout.Dimensions{Size: gtx.Constraints.Min} } } - -func Main() { - mode := flag.String("mode", "", "window mode: desktop or phone") - stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") - grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") - flag.Parse() - - resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) - resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") - resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) - - width := unit.Dp(1180) - height := unit.Dp(760) - if strings.EqualFold(resolvedMode, "phone") { - // Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview. - width = unit.Dp(412) - height = unit.Dp(915) - } - - go func() { - w := new(app.Window) - options := []app.Option{app.Title(productName)} - if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) { - options = append(options, app.Size(width, height)) - } - w.Option(options...) - if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { - panic(err) - } - if !strings.EqualFold(runtime.GOOS, "android") { - os.Exit(0) - } - }() - app.Main() -} - -func defaultGRPCAddr(goos string) string { - if strings.EqualFold(strings.TrimSpace(goos), "android") { - return "off" - } - return "127.0.0.1:47777" -} - -func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { - var ops op.Ops - manager := &session.Manager{} - ui := newUIWithSession(mode, manager, paths) - ui.fileExplorer = explorer.NewExplorer(w) - ui.invalidate = w.Invalidate - ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate) - host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) - if err != nil { - ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) - } else if host != nil { - ui.apiHost = host - ui.auditLog = host.Server().AuditLog() - ui.grpcAddress = host.Address() - ui.state.Approvals = &uiApprovalManager{server: host.Server()} - defer func() { _ = host.Stop() }() - } - for { - e := w.Event() - ui.fileExplorer.ListenEvents(e) - switch e := e.(type) { - case app.DestroyEvent: - return e.Err - case app.FrameEvent: - gtx := app.NewContext(&ops, e) - ui.processBackgroundActions() - ui.layout(gtx) - platform.ProcessClipboardWrites(gtx, ui.clipboardWriter) - e.Frame(gtx.Ops) - } - } -} - -type uiApprovalManager struct { - server *api.Server -} - -func (m *uiApprovalManager) Pending() []apiapproval.Request { - if m == nil || m.server == nil { - return nil - } - return m.server.ApprovalBroker().Pending() -} - -func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { - if m == nil || m.server == nil { - return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") - } - return m.server.ResolveApproval(id, outcome) -} - -type uiSession struct { - model vault.Model - locked bool -} - -func (s *uiSession) HasVault() bool { - return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked -} - -func (s *uiSession) IsLocked() bool { - return s.locked -} - -func (s *uiSession) IsRemote() bool { - return false -} - -func (s *uiSession) Current() (vault.Model, error) { - if s.locked { - return vault.Model{}, session.ErrLocked - } - return s.model, nil -} - -func (s *uiSession) Replace(model vault.Model) { - s.model = model -} - -func (s *uiSession) Lock() error { - s.locked = true - return nil -} - -func (s *uiSession) Unlock(vault.MasterKey) error { - if !s.locked { - return nil - } - s.locked = false - return nil -} - -func pickExistingFile() (string, error) { - if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil { - return path, nil - } - if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil { - return path, nil - } - return "", fmt.Errorf("no supported file picker found; install kdialog or zenity") -} - -func runFilePicker(name string, args ...string) (string, error) { - if _, err := exec.LookPath(name); err != nil { - return "", err - } - cmd := exec.Command(name, args...) - output, err := cmd.Output() - if err != nil { - return "", err - } - return parsePickedFilePath(output) -} - -func parsePickedFilePath(output []byte) (string, error) { - lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" { - continue - } - if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") { - return line, nil - } - } - return "", fmt.Errorf("file picker did not return a path") -} diff --git a/internal/appui/layout/detail/mode.go b/internal/appui/layout/detail/mode.go new file mode 100644 index 0000000..b19ea51 --- /dev/null +++ b/internal/appui/layout/detail/mode.go @@ -0,0 +1,26 @@ +package detail + +type Mode string + +const ( + ModeLocked Mode = "locked" + ModeStatic Mode = "static" + ModeEmpty Mode = "empty" + ModeEditor Mode = "editor" + ModeView Mode = "view" +) + +func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode { + switch { + case isLocked: + return ModeLocked + case hasStaticPanel: + return ModeStatic + case !hasSelectedEntry && !editing: + return ModeEmpty + case editing: + return ModeEditor + default: + return ModeView + } +} diff --git a/internal/appui/layout/dropdown.go b/internal/appui/layout/header/dropdown.go similarity index 92% rename from internal/appui/layout/dropdown.go rename to internal/appui/layout/header/dropdown.go index 91d5430..0e18456 100644 --- a/internal/appui/layout/dropdown.go +++ b/internal/appui/layout/header/dropdown.go @@ -1,4 +1,4 @@ -package layout +package header import ( "image" @@ -63,7 +63,7 @@ func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu la return layout.Dimensions{Size: gtx.Constraints.Max} } -type HeaderActionMetrics struct { +type ActionMetrics struct { RowOriginX int Spacing int RowDims layout.Dimensions @@ -72,14 +72,14 @@ type HeaderActionMetrics struct { MainDims layout.Dimensions } -func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor { +func (m ActionMetrics) SyncAnchor() DropdownAnchor { return DropdownAnchor{ TriggerRightX: m.RowOriginX + m.SyncDims.Size.X, TriggerBottomY: m.RowDims.Size.Y, } } -func (m HeaderActionMetrics) MainAnchor() DropdownAnchor { +func (m ActionMetrics) MainAnchor() DropdownAnchor { triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X return DropdownAnchor{ TriggerRightX: m.RowOriginX + triggerRightX, diff --git a/internal/appui/layout/list/sections.go b/internal/appui/layout/list/sections.go new file mode 100644 index 0000000..e6c6fb1 --- /dev/null +++ b/internal/appui/layout/list/sections.go @@ -0,0 +1,12 @@ +package list + +type TopSection string + +const ( + TopSearch TopSection = "search" + TopNavigation TopSection = "navigation" + TopPath TopSection = "path" + TopGroup TopSection = "group" + TopGroupTools TopSection = "group_tools" + TopPrimary TopSection = "primary" +) diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index cf09378..359b437 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -25,7 +25,8 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" - appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" + listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" @@ -260,13 +261,13 @@ func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T phone := newUIWithModel("phone", vault.Model{}) phone.state.Section = appstate.SectionEntries - want := []listPanelTopSection{ - listPanelTopSearch, - listPanelTopNavigation, - listPanelTopPath, - listPanelTopGroup, - listPanelTopGroupTools, - listPanelTopPrimary, + want := []listlayout.TopSection{ + listlayout.TopSearch, + listlayout.TopNavigation, + listlayout.TopPath, + listlayout.TopGroup, + listlayout.TopGroupTools, + listlayout.TopPrimary, } if got := desktop.listPanelTopSections(); !slices.Equal(got, want) { t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want) @@ -373,10 +374,10 @@ func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) { func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { t.Parallel() - if got := appuilayout.AnchoredMenuX(48, 160); got != -112 { + if got := headerlayout.AnchoredMenuX(48, 160); got != -112 { t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got) } - if got := appuilayout.AnchoredMenuX(160, 48); got != 112 { + if got := headerlayout.AnchoredMenuX(160, 48); got != 112 { t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got) } } @@ -384,10 +385,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { t.Parallel() - if got := appuilayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 { + if got := headerlayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 { t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got) } - if got := appuilayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 { + if got := headerlayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 { t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got) } } @@ -395,7 +396,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { t.Parallel() - metrics := appuilayout.HeaderActionMetrics{ + metrics := headerlayout.ActionMetrics{ RowOriginX: 24, Spacing: 8, RowDims: layout.Dimensions{Size: image.Pt(180, 40)}, @@ -404,10 +405,10 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { MainDims: layout.Dimensions{Size: image.Pt(36, 40)}, } - if got := metrics.SyncAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { + if got := metrics.SyncAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got) } - if got := metrics.MainAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { + if got := metrics.MainAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got) } } @@ -415,14 +416,14 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) { t.Parallel() - surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} - anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} + surface := headerlayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} + anchor := headerlayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) { t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got) } - leftAnchor := appuilayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} + leftAnchor := headerlayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) { t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got) } diff --git a/internal/appui/ui_actions_lifecycle.go b/internal/appui/ui_actions_lifecycle.go new file mode 100644 index 0000000..98d45e6 --- /dev/null +++ b/internal/appui/ui_actions_lifecycle.go @@ -0,0 +1,778 @@ +package appui + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "gioui.org/layout" + "gioui.org/x/explorer" + "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" +) + +func (u *ui) createVaultAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + if err := u.state.ConfigureSecurity(vault.SecuritySettings{ + Cipher: strings.TrimSpace(u.securityCipher.Text()), + KDF: strings.TrimSpace(u.securityKDF.Text()), + }); err != nil { + return err + } + if err := u.state.CreateVault(key); err != nil { + 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 + } + u.vaultPath.SetText(u.saveAsTargetPath()) + u.noteRecentVault(u.saveAsTargetPath()) + } + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) openVaultAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.OpenVault(path, key); err != nil { + return err + } + u.noteRecentVault(path) + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.restoreRecentVaultGroup(path) + u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + u.showStatusMessage("Remote sync on open failed: " + err.Error()) + } + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + u.applyPendingLifecycleOpenIntent() + return nil +} + +func (u *ui) startOpenVaultAction() { + manager, ok := u.state.Session.(*session.Manager) + if !ok { + u.runAction("open vault", u.openVaultAction) + return + } + key, err := u.currentMasterKey() + u.clearMasterPassword() + if err != nil { + u.state.ErrorMessage = u.describeActionError("open vault", err) + u.requestMasterPassFocus = true + return + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) + u.requestMasterPassFocus = true + return + } + u.lastLifecycleAction = "open vault" + u.runBackgroundAction("open vault", func() (func() error, error) { + prepared, err := session.PrepareLocalOpen(path, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedLocalOpen(prepared) + u.noteRecentVault(path) + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.restoreRecentVaultGroup(path) + u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + u.showStatusMessage("Remote sync on open failed: " + err.Error()) + } + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + u.applyPendingLifecycleOpenIntent() + return nil + }, nil + }) +} + +func (u *ui) shouldShowLifecycleRemoteSyncAction() bool { + return strings.TrimSpace(u.vaultPath.Text()) != "" +} + +func (u *ui) lifecycleRemoteSyncActionLabel() string { + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Open Vault And Set Up Remote Sync" + } + if hasBoundRecentRemote(u.recentRemotes, path) { + return "Open Vault And Open Remote Sync Settings" + } + return "Open Vault And Set Up Remote Sync" +} + +func (u *ui) beginLifecycleRemoteSyncOpen() { + path := strings.TrimSpace(u.vaultPath.Text()) + switch { + case path == "": + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + case hasBoundRecentRemote(u.recentRemotes, path): + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings + default: + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup + } + u.startOpenVaultAction() +} + +func (u *ui) applyPendingLifecycleOpenIntent() { + intent := u.pendingLifecycleOpenIntent + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + switch intent { + case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings: + u.openRemoteSyncSetupDialog() + } +} + +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 +} + +func (u *ui) saveAsAction() error { + path := u.saveAsTargetPath() + if err := u.state.SaveAs(path); err != nil { + return err + } + u.vaultPath.SetText(path) + u.noteRecentVault(path) + u.filter() + return nil +} + +func (u *ui) openRemoteAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil { + return err + } else if ok { + if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { + return err + } + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + } + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Username: strings.TrimSpace(u.remoteUsername.Text()), + Password: u.remotePassword.Text(), + } + if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { + return err + } + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } + u.noteRecentRemote( + strings.TrimSpace(u.remoteBaseURL.Text()), + strings.TrimSpace(u.remotePath.Text()), + ) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) startOpenRemoteAction() { + manager, ok := u.state.Session.(*session.Manager) + if !ok { + u.runAction("open remote vault", u.openRemoteAction) + return + } + key, err := u.currentMasterKey() + u.clearMasterPassword() + if err != nil { + u.state.ErrorMessage = u.describeActionError("open remote vault", err) + u.requestMasterPassFocus = true + return + } + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Username: strings.TrimSpace(u.remoteUsername.Text()), + Password: u.remotePassword.Text(), + } + remotePath := strings.TrimSpace(u.remotePath.Text()) + u.lastLifecycleAction = "open remote vault" + u.runBackgroundAction("open remote vault", func() (func() error, error) { + binding, bindingOK := u.selectedVaultRemoteBinding() + if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" { + preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key) + if err != nil { + return nil, err + } + resolved, err := binding.Resolve(preparedLocal.Model) + if err != nil { + return nil, err + } + preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + }, resolved.Profile.Path, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedLocalOpen(preparedLocal) + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + manager.ApplyPreparedRemoteOpen(preparedRemote) + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + } + if u.hasOpenVault() { + if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + return nil, err + } else if ok { + client = webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + remotePath = resolved.Profile.Path + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + } + } + prepared, err := session.PrepareRemoteOpen(client, remotePath, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedRemoteOpen(prepared) + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } + u.noteRecentRemote( + strings.TrimSpace(u.remoteBaseURL.Text()), + remotePath, + ) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + }) +} + +func (u *ui) lockAction() error { + u.clearMasterPassword() + if err := u.state.Lock(); err != nil { + return err + } + u.requestMasterPassFocus = true + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.resetPasswordPeek() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) unlockAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + if err := u.state.Unlock(key); err != nil { + return err + } + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) startUnlockAction() { + manager, ok := u.state.Session.(*session.Manager) + if !ok { + u.runAction("unlock vault", u.unlockAction) + return + } + key, err := u.currentMasterKey() + u.clearMasterPassword() + if err != nil { + u.state.ErrorMessage = u.describeActionError("unlock vault", err) + u.requestMasterPassFocus = true + return + } + encoded := append([]byte(nil), manager.EncodedBytes()...) + u.runBackgroundAction("unlock vault", func() (func() error, error) { + prepared, err := session.PrepareUnlock(encoded, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedUnlock(prepared) + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + }) +} + +func (u *ui) changeMasterKeyAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + return u.state.ChangeMasterKey(key) +} + +func (u *ui) loadSecuritySettingsFromSession() { + settings, err := u.state.SecuritySettings() + if err != nil { + return + } + u.securityCipher.SetText(settings.Cipher) + u.securityKDF.SetText(settings.KDF) +} + +func (u *ui) clearMasterPassword() { + u.masterPassword.SetText("") +} + +func (u *ui) synchronizeAction() error { + if err := u.state.Synchronize(); err != nil { + return err + } + u.syncMenuOpen = false + u.filter() + return nil +} + +func (u *ui) openAdvancedSyncDialog() { + u.syncDialogOpen = true + u.syncMenuOpen = false + u.showSyncPassword = false + u.syncDialogList.Position = layout.Position{} + u.syncDialogPurpose = syncDialogPurposeAdvanced + u.syncSourceMode = u.syncDefaultSourceMode + u.syncDirection = u.syncDefaultDirection + if strings.TrimSpace(u.syncLocalPath.Text()) == "" { + u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) + } + u.syncSavedRemoteBindingSelection() + u.prefillAdvancedSyncRemoteFromSavedBinding() +} + +func (u *ui) openRemoteSyncSetupDialog() { + u.syncDialogOpen = true + u.syncMenuOpen = false + u.showSyncPassword = false + u.syncDialogList.Position = layout.Position{} + u.syncDialogPurpose = syncDialogPurposeRemoteSetup + u.syncSourceMode = syncSourceRemote + u.syncDirection = syncDirectionPush + u.syncSetupAutomatic.Value = true + if strings.TrimSpace(u.syncLocalPath.Text()) == "" { + u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) + } + u.syncSavedRemoteBindingSelection() + u.prefillAdvancedSyncRemoteFromSavedBinding() + if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual { + u.syncSetupAutomatic.Value = false + } +} + +func (u *ui) clearSyncLocalImport() { + u.syncLocalImportName = "" + u.syncLocalImportContent = nil +} + +func (u *ui) selectedSyncLocalImport() (string, []byte, bool) { + name := strings.TrimSpace(u.syncLocalImportName) + if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 { + return "", nil, false + } + return name, append([]byte(nil), u.syncLocalImportContent...), true +} + +func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode { + switch mode { + case syncSourceRemote: + return syncSourceRemote + default: + return syncSourceLocal + } +} + +func sanitizeSyncDirection(direction syncDirection) syncDirection { + switch direction { + case syncDirectionPush: + return syncDirectionPush + default: + return syncDirectionPull + } +} + +func (u *ui) advancedSyncAction() error { + switch u.syncDirection { + case syncDirectionPush: + return u.advancedSyncToAction() + default: + return u.advancedSyncFromAction() + } +} + +func (u *ui) advancedSyncFromAction() error { + switch u.syncSourceMode { + case syncSourceRemote: + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + } + if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { + return err + } + default: + if name, content, ok := u.selectedSyncLocalImport(); ok { + if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil { + return err + } + break + } + path := strings.TrimSpace(u.syncLocalPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.SynchronizeFromLocal(path); err != nil { + return err + } + } + u.syncDialogOpen = false + u.showSyncPassword = false + u.filter() + return nil +} + +func (u *ui) startChooseSyncLocalSourceAction() { + if runtime.GOOS != "android" || u.fileExplorer == nil { + u.runAction("choose sync path", func() error { + u.clearSyncLocalImport() + return u.chooseExistingFileAction(&u.syncLocalPath) + }) + return + } + u.runBackgroundAction("choose sync file", func() (func() error, error) { + file, err := u.fileExplorer.ChooseFile(".kdbx") + if err != nil { + if errors.Is(err, explorer.ErrUserDecline) { + return func() error { return nil }, nil + } + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + label := "Selected Android vault" + return func() error { + u.syncLocalImportName = label + u.syncLocalImportContent = append([]byte(nil), content...) + u.syncLocalPath.SetText(label) + return nil + }, nil + }) +} + +func pickedDocumentName(file io.ReadCloser, fallback string) string { + if named, ok := file.(interface{ Name() string }); ok { + if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) { + return base + } + } + fallback = filepath.Base(strings.TrimSpace(fallback)) + if fallback == "" || fallback == "." || fallback == string(filepath.Separator) { + return "selected-vault.kdbx" + } + return fallback +} + +func (u *ui) startChooseVaultPathAction() { + if runtime.GOOS != "android" || u.fileExplorer == nil { + u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) + return + } + u.runBackgroundAction("choose vault file", func() (func() error, error) { + file, err := u.fileExplorer.ChooseFile(".kdbx") + if err != nil { + if errors.Is(err, explorer.ErrUserDecline) { + return func() error { return nil }, nil + } + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + name := pickedDocumentName(file, "selected-vault.kdbx") + return func() error { + return u.importSharedVaultBytesAction(name, content) + }, nil + }) +} + +func (u *ui) startImportSharedVaultAction() { + if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil { + return + } + u.runBackgroundAction("import shared vault", func() (func() error, error) { + file, err := u.fileExplorer.ChooseFile(".kdbx") + if err != nil { + if errors.Is(err, explorer.ErrUserDecline) { + return func() error { return nil }, nil + } + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + return func() error { + return u.importSharedVaultBytesAction("shared-vault.kdbx", content) + }, nil + }) +} + +func (u *ui) advancedSyncToAction() error { + switch u.syncSourceMode { + case syncSourceRemote: + baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.syncRemotePath.Text()) + client := webdav.Client{ + BaseURL: baseURL, + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + } + if err := u.state.SynchronizeToRemote(client, remotePath); err != nil { + return err + } + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil { + return err + } + u.showStatusMessage("Remote sync is set up for this vault.") + } + default: + path := strings.TrimSpace(u.syncLocalPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.SynchronizeToLocal(path); err != nil { + return err + } + } + u.syncDialogOpen = false + u.showSyncPassword = false + u.filter() + return nil +} + +func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error { + baseURL = strings.TrimSpace(baseURL) + remotePath = strings.TrimSpace(remotePath) + if baseURL == "" || remotePath == "" { + return fmt.Errorf("remote setup requires base URL and path") + } + input := appstate.RemoteBindingInput{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), + RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), + BaseURL: baseURL, + RemotePath: remotePath, + CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), + CredentialTitle: "WebDAV Sign-In" + func() string { + if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" { + return " · " + user + } + return "" + }(), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + CredentialPath: append([]string(nil), u.currentPath...), + SyncMode: u.syncSetupMode(), + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + u.remoteBaseURL.SetText(baseURL) + u.remotePath.SetText(remotePath) + u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text())) + u.remotePassword.SetText(u.syncRemotePassword.Text()) + u.noteRecentRemote(baseURL, remotePath) + return nil +} + +func (u *ui) saveAsTargetPath() string { + path := strings.TrimSpace(u.saveAsPath.Text()) + if path != "" { + return path + } + return u.defaultSaveAsPath +} + +func (u *ui) importedVaultDestination(name string) string { + baseTarget := u.saveAsTargetPath() + baseDir := filepath.Dir(baseTarget) + baseName := filepath.Base(strings.TrimSpace(name)) + switch { + case baseName == "" || baseName == "." || baseName == string(filepath.Separator): + return baseTarget + case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"): + return filepath.Join(baseDir, baseName) + default: + return baseTarget + } +} + +func (u *ui) consumePendingSharedVaultImport() { + path := strings.TrimSpace(u.pendingSharedVaultPath) + if path == "" { + return + } + content, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + } + return + } + name := "shared-vault.kdbx" + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + if rawName, err := os.ReadFile(namePath); err == nil { + if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" { + name = trimmed + } + } + } + if err := u.importSharedVaultBytesAction(name, content); err != nil { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + return + } + _ = os.Remove(path) + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + _ = os.Remove(namePath) + } +} + +func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { + target := u.importedVaultDestination(name) + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil { + return err + } + u.lifecycleMode = "local" + u.vaultPath.SetText(target) + u.noteRecentVault(target) + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.requestMasterPassFocus = true + u.filter() + return nil +} + +func (u *ui) currentShareableVaultPath() string { + return strings.TrimSpace(u.vaultPath.Text()) +} + +func (u *ui) shareCurrentVaultAction() error { + if u.vaultSharer == nil { + return fmt.Errorf("vault sharing is not available on this platform") + } + path := u.currentShareableVaultPath() + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.Save(); err != nil { + return err + } + return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path)) +} diff --git a/internal/appui/ui_frame.go b/internal/appui/ui_frame.go new file mode 100644 index 0000000..85b5396 --- /dev/null +++ b/internal/appui/ui_frame.go @@ -0,0 +1,1375 @@ +package appui + +import ( + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + "time" + + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/session" +) + +func (u *ui) bannerSurface() uiBanner { + switch { + case strings.TrimSpace(u.loadingMessage) != "": + return uiBanner{ + Kind: bannerLoading, + Message: strings.TrimSpace(u.loadingMessage), + Detail: u.loadingDetailMessage(), + } + case strings.TrimSpace(u.state.ErrorMessage) != "": + return uiBanner{ + Kind: bannerError, + Message: strings.TrimSpace(u.state.ErrorMessage), + Dismissable: true, + } + default: + return uiBanner{} + } +} + +func (u *ui) statusToastSurface() uiBanner { + if strings.TrimSpace(u.state.StatusMessage) == "" { + return uiBanner{} + } + if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return uiBanner{} + } + return uiBanner{ + Kind: bannerStatus, + Message: strings.TrimSpace(u.state.StatusMessage), + } +} + +func (u *ui) autofillStatusSurface() uiAutofillStatus { + if u.autofillNoticePreference == autofillNoticeSuppressed { + return uiAutofillStatus{} + } + if request, ok := u.pendingAutofillApproval(); ok { + detail := approvalResourceText(request) + if strings.TrimSpace(detail) == "" { + detail = "Review the request to allow or deny this fill attempt." + } + return uiAutofillStatus{ + Kind: autofillStatusAwaitingApproval, + Title: "Autofill approval needed", + Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.", + Detail: detail, + } + } + if u.auditLog == nil { + return uiAutofillStatus{} + } + if u.autofillNoticePreference == autofillNoticeApprovals { + return uiAutofillStatus{} + } + for _, event := range u.auditLog.Events() { + if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { + return status + } + } + return uiAutofillStatus{} +} + +func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) { + for _, request := range u.state.PendingApprovals() { + if isAutofillOperation(request.Operation) { + return request, true + } + } + return apiapproval.Request{}, false +} + +func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) { + if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL { + return uiAutofillStatus{}, false + } + + requester := formatAutofillRequester(event.ClientName, event.TokenName) + switch event.Type { + case apiaudit.EventAutofillFound: + return uiAutofillStatus{ + Kind: autofillStatusFound, + Title: "Autofill match ready", + Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventAutofillAmbiguous: + return uiAutofillStatus{ + Kind: autofillStatusAmbiguous, + Title: "Autofill needs a narrower match", + Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventAutofillBlocked: + return uiAutofillStatus{ + Kind: autofillStatusBlocked, + Title: "Autofill is blocked", + Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventApprovalAllowed: + if !isAutofillOperation(event.Operation) { + return uiAutofillStatus{}, false + } + return uiAutofillStatus{ + Kind: autofillStatusFound, + Title: "Autofill approved", + Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut: + if !isAutofillOperation(event.Operation) { + return uiAutofillStatus{}, false + } + return uiAutofillStatus{ + Kind: autofillStatusBlocked, + Title: "Autofill was not allowed", + Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)), + Detail: autofillEventDetail(event), + }, true + default: + return uiAutofillStatus{}, false + } +} + +func autofillEventDetail(event apiaudit.Event) string { + return strings.TrimSpace(resourceDetailText(event.Resource)) +} + +func resourceDetailText(resource apitokens.Resource) string { + switch resource.Kind { + case apitokens.ResourceEntry: + if entryID := strings.TrimSpace(resource.EntryID); entryID != "" { + return "Entry ID: " + entryID + } + case apitokens.ResourceGroup: + if len(resource.Path) > 0 { + return "Group: " + strings.Join(resource.Path, " / ") + } + } + return "" +} + +func formatAutofillRequester(clientName, tokenName string) string { + switch { + case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "": + return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")" + case strings.TrimSpace(clientName) != "": + return strings.TrimSpace(clientName) + case strings.TrimSpace(tokenName) != "": + return strings.TrimSpace(tokenName) + default: + return "A trusted client" + } +} + +func defaultAutofillMessage(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} + +func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string { + switch eventType { + case apiaudit.EventApprovalDenied: + return requester + " was denied for this fill request." + case apiaudit.EventApprovalCanceled: + return requester + " canceled this fill request." + case apiaudit.EventApprovalTimedOut: + return requester + " timed out while waiting for approval." + default: + return requester + " could not fill this target." + } +} + +func isAutofillOperation(operation apitokens.Operation) bool { + switch operation { + case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL: + return true + default: + return false + } +} + +func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { + if !u.shouldShowLifecycleSetup() { + if banner.Dismissable { + return "", "Dismiss" + } + return "", "" + } + switch banner.Kind { + case bannerLoading: + if strings.HasPrefix(u.loadingActionLabel, "open ") { + return "Cancel", "" + } + case bannerError: + if u.canRetryLifecycleOpen() { + return "Retry", "Dismiss" + } + if banner.Dismissable { + return "", "Dismiss" + } + } + return "", "" +} + +func (u *ui) loadingDetailMessage() string { + if !u.shouldShowLifecycleSetup() { + return "" + } + if u.lifecycleMode == "remote" { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + switch { + case baseURL != "" && path != "": + return fmt.Sprintf( + "Target: %s (%s)", + friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), + path, + ) + case baseURL != "": + return "Target: " + baseURL + default: + return "Preparing remote vault access" + } + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Preparing local vault access" + } + return "Target: " + path +} + +func (u *ui) currentVaultSummary() vaultSummary { + status, ok := u.state.Session.(sessionStatus) + if !ok || !status.HasVault() { + return vaultSummary{} + } + if status.IsRemote() { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + summary := vaultSummary{ + Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), + Detail: baseURL, + } + if strings.TrimSpace(summary.Title) == "" { + summary.Title = "Remote vault" + } + summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path)) + return summary + } + path := strings.TrimSpace(u.vaultPath.Text()) + summary := vaultSummary{ + Title: friendlyRecentVaultLabel(path), + Detail: path, + } + if strings.TrimSpace(summary.Title) == "" { + summary.Title = "Local vault" + } + summary.Context = u.vaultResumeContext(u.recentVaultGroup(path)) + return summary +} + +func (u *ui) vaultResumeContext(path []string) string { + if len(path) == 0 { + return "" + } + displayPath := append([]string(nil), path...) + if len(displayPath) == 0 { + return "" + } + return "Resume in: " + strings.Join(displayPath, " / ") +} + +func compactPathDirectorySummary(path string) string { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "." || cleaned == "" { + return "" + } + dir := filepath.Dir(cleaned) + if dir == "." || dir == cleaned { + return "" + } + if dir == string(filepath.Separator) { + return dir + } + parts := strings.Split(filepath.ToSlash(dir), "/") + filtered := parts[:0] + for _, part := range parts { + if strings.TrimSpace(part) != "" { + filtered = append(filtered, part) + } + } + parts = filtered + if len(parts) <= 2 { + return filepath.ToSlash(dir) + } + return parts[0] + "/.../" + parts[len(parts)-1] +} + +func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { + if !u.requestMasterPassFocus { + return + } + gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) + gtx.Execute(op.InvalidateCmd{}) + u.requestMasterPassFocus = false +} + +func (u *ui) sessionSurface() uiSurface { + if u.state.Session == nil { + return uiSurface{} + } + if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { + return uiSurface{ + Title: "Vault locked", + Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", + Locked: true, + } + } + return uiSurface{} +} + +func (u *ui) hasOpenVault() bool { + status, ok := u.state.Session.(sessionStatus) + if ok { + return status.HasVault() + } + _, err := u.state.Session.Current() + return err == nil +} + +func (u *ui) isVaultLocked() bool { + status, ok := u.state.Session.(sessionStatus) + if ok { + return status.IsLocked() + } + _, err := u.state.Session.Current() + return errors.Is(err, session.ErrLocked) +} + +func (u *ui) shouldShowLifecycleSetup() bool { + return !u.hasOpenVault() +} + +func (u *ui) lifecycleBusy() bool { + return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" +} + +func (u *ui) updateViewportLayoutMode(gtx layout.Context) { + u.viewportMeasured = true + u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) +} + +func (u *ui) usesCompactViewport() bool { + if u.viewportMeasured { + return u.compactViewport + } + return u.mode == "phone" +} + +func (u *ui) shouldUseLockedSinglePane() bool { + return u.isVaultLocked() && !u.shouldShowLifecycleSetup() +} + +func (u *ui) shouldShowDesktopWorkingHeader() bool { + return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() +} + +func (u *ui) shouldUseCompactPhoneDetailPane() bool { + if !u.usesCompactViewport() { + return false + } + if u.isVaultLocked() || u.editingEntry { + return false + } + _, ok := u.selectedEntry() + return !ok +} + +func (u *ui) chooseExistingFileAction(target *widget.Editor) error { + path, err := pickExistingFile() + if err != nil { + return err + } + target.SetText(path) + return nil +} + +func (u *ui) listEmptyMessage() string { + return u.listEmptyState().Body +} + +func (u *ui) listEmptyState() emptyState { + if surface := u.sessionSurface(); surface.Locked { + return emptyState{ + Title: "Vault locked", + Body: "Unlock the vault to browse entries and groups.", + } + } + query := strings.TrimSpace(u.search.Text()) + if query != "" { + switch u.state.Section { + case appstate.SectionAPITokens: + return emptyState{ + Title: "No matching API tokens", + Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query), + } + case appstate.SectionAPIAudit: + return emptyState{ + Title: "No matching audit events", + Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query), + } + case appstate.SectionTemplates: + return emptyState{ + Title: "No matching templates", + Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), + } + case appstate.SectionRecycleBin: + return emptyState{ + Title: "No matching deleted entries", + Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), + } + default: + return emptyState{ + Title: "No matching entries", + Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), + } + } + } + switch u.state.Section { + case appstate.SectionAPITokens: + return emptyState{ + Title: "No API tokens yet", + Body: "Issue a token to grant scoped gRPC access to an external tool.", + } + case appstate.SectionAPIAudit: + return emptyState{ + Title: "No API audit events yet", + Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", + } + case appstate.SectionAbout: + return emptyState{ + Title: "About KeePassGO", + Body: "Product details, compatibility notes, and platform targets appear in the detail pane.", + } + case appstate.SectionTemplates: + return emptyState{ + Title: "Templates unavailable", + Body: "Templates are not available in this build.", + } + case appstate.SectionRecycleBin: + return emptyState{ + Title: "Recycle Bin is empty", + Body: "Deleted entries will appear here until restored.", + } + default: + if len(u.displayPath()) > 0 { + return emptyState{ + Title: "This group is empty", + Body: "Add an entry here, search below this point, or open a subgroup.", + } + } + return emptyState{ + Title: "No entries yet", + Body: "Create or open a vault, then add an entry to get started.", + } + } +} + +func (u *ui) detailPlaceholderMessage() string { + if surface := u.sessionSurface(); surface.Locked { + return "Unlock the vault to inspect entries, attachments, and history." + } + if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || + strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || + strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { + return "Complete the form to create a new item or update the current selection." + } + switch u.state.Section { + case appstate.SectionAPITokens: + return "Select an API token, issue a new one, or search to narrow the list." + case appstate.SectionAPIAudit: + return "Select an audit event to inspect it, or use Search audit log or the quick filters above." + case appstate.SectionAbout: + return "Review the product overview, platform support, and compatibility goals." + case appstate.SectionTemplates: + return "Select a template or start a reusable entry." + case appstate.SectionRecycleBin: + return "Select a deleted entry to review or restore it." + default: + if strings.TrimSpace(u.search.Text()) != "" { + return "Select a matching entry from the filtered list or clear the search." + } + if len(u.displayPath()) == 0 { + return "Select an entry from the vault root or open a group." + } + return "Select an entry or start a new one." + } +} + +func (u *ui) ensureNavClickables() { + u.syncCurrentPath() + if len(u.breadcrumbs) < len(u.currentPath)+1 { + u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) + } +} + +func (u *ui) syncPhoneGroupBrowser(path []string) { + if !u.usesCompactViewport() { + return + } + u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 +} + +func (u *ui) setCurrentPath(path []string) { + u.currentPath = append([]string(nil), path...) + u.state.NavigateToPath(path) + u.syncedPath = append([]string(nil), path...) + u.syncPhoneGroupBrowser(path) + u.noteCurrentVaultPath() + u.clearDeleteGroupConfirmation() +} + +func (u *ui) syncCurrentPath() { + switch { + case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): + u.currentPath = append([]string(nil), u.state.CurrentPath...) + case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): + u.state.CurrentPath = append([]string(nil), u.currentPath...) + case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): + u.state.CurrentPath = append([]string(nil), u.currentPath...) + } + u.syncedPath = append([]string(nil), u.currentPath...) + u.noteCurrentVaultPath() + if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) { + u.clearDeleteGroupConfirmation() + } +} + +func (u *ui) noteCurrentVaultPath() { + status, ok := u.state.Session.(sessionStatus) + if !ok || status.IsLocked() { + return + } + if status.IsRemote() { + u.noteCurrentRemotePath() + return + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return + } + if u.recentVaultGroups == nil { + u.recentVaultGroups = map[string][]string{} + } + u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) + u.saveRecentVaults() +} + +func (u *ui) layout(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) + u.phoneSyncMenuVisible = false + u.phoneMainMenuVisible = false + u.syncHostedAPI() + u.filter() + u.processShortcuts(gtx) + u.handleLifecycleClicks(gtx) + u.handleHeaderAndDialogClicks(gtx) + u.handleSettingsClicks(gtx) + u.handleSectionAndSyncClicks(gtx) + u.handleApprovalAndAPIClicks(gtx) + u.handleSelectionClicks(gtx) + u.handleVaultAndEntryClicks(gtx) + u.handleGroupClicks(gtx) + u.handleInputUpdates(gtx) + u.updateViewportLayoutMode(gtx) + inset := layout.UniformInset(unit.Dp(16)) + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { + return inset.Layout(gtx, u.mainFrame) + }) + }), + layout.Stacked(u.syncDialogOverlay), + layout.Stacked(u.securityDialogOverlay), + layout.Stacked(u.remotePrefsDialogOverlay), + layout.Stacked(u.approvalDialogOverlay), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return u.phoneHeaderMenus(gtx) + }), + layout.Stacked(u.statusToast), + ) +} + +func (u *ui) handleLifecycleClicks(gtx layout.Context) { + for u.createVault.Clicked(gtx) { + u.runAction("create vault", u.createVaultAction) + } + for u.openVault.Clicked(gtx) { + u.startOpenVaultAction() + } + for u.lifecycleRemoteSyncAction.Clicked(gtx) { + if !u.lifecycleBusy() { + u.beginLifecycleRemoteSyncOpen() + } + } + for u.unlockVault.Clicked(gtx) { + u.startUnlockAction() + } + for u.cancelLifecycleProgress.Clicked(gtx) { + u.cancelLifecycleBusyState() + } + for u.retryLifecycleOpen.Clicked(gtx) { + u.state.ErrorMessage = "" + u.retryLastLifecycleOpen() + } + for u.toggleLifecycleAdvanced.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden + u.saveUIPreferences() + } + } +} + +func (u *ui) handleHeaderAndDialogClicks(gtx layout.Context) { + u.handleHeaderActionClicks(gtx) + u.handleDialogControlClicks(gtx) + u.handleBannerClicks(gtx) +} + +func (u *ui) handleHeaderActionClicks(gtx layout.Context) { + for u.saveVault.Clicked(gtx) { + u.runAction("save vault", u.saveAction) + } + for u.saveAsVault.Clicked(gtx) { + u.runAction("save-as vault", u.saveAsAction) + } + for u.openRemote.Clicked(gtx) { + u.startOpenRemoteAction() + } + for u.changeMasterKey.Clicked(gtx) { + u.runAction("change master key", u.changeMasterKeyAction) + } + for u.synchronizeVault.Clicked(gtx) { + u.runAction("synchronize vault", u.synchronizeAction) + } + for u.toggleSyncMenu.Clicked(gtx) { + u.syncMenuOpen = !u.syncMenuOpen + if u.syncMenuOpen { + u.mainMenuOpen = false + } + } + for u.toggleMainMenu.Clicked(gtx) { + u.mainMenuOpen = !u.mainMenuOpen + if u.mainMenuOpen { + u.syncMenuOpen = false + } + } + for u.openAdvancedSync.Clicked(gtx) { + u.openAdvancedSyncDialog() + } + for u.openSecuritySettings.Clicked(gtx) { + u.loadSecuritySettingsFromSession() + u.loadSettingsFormFromPreferences() + u.loadSettingsDraft() + u.mainMenuOpen = false + u.securityDialogOpen = true + } + for u.openRemotePrefsHelp.Clicked(gtx) { + u.remotePrefsDialogOpen = true + } + for u.lockVault.Clicked(gtx) { + u.runAction("lock vault", u.lockAction) + } +} + +func (u *ui) handleDialogControlClicks(gtx layout.Context) { + for u.closeAdvancedSync.Clicked(gtx) { + u.syncDialogOpen = false + u.showSyncPassword = false + } + for u.closeSecuritySettings.Clicked(gtx) { + u.securityDialogOpen = false + } + for u.closeRemotePrefsHelp.Clicked(gtx) { + u.remotePrefsDialogOpen = false + } + for u.runAdvancedSync.Clicked(gtx) { + u.runAction("advanced synchronize vault", u.advancedSyncAction) + } + for u.saveSecuritySettings.Clicked(gtx) { + u.runAction("save settings", u.saveSecuritySettingsAction) + } +} + +func (u *ui) handleBannerClicks(gtx layout.Context) { + for u.dismissBanner.Clicked(gtx) { + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + } +} + +func (u *ui) handleSettingsClicks(gtx layout.Context) { + u.handleStatusPreferenceClicks(gtx) + u.handleAutofillPreferenceClicks(gtx) + u.handleAccessibilityClicks(gtx) + u.handleSettingsSyncDefaultClicks(gtx) +} + +func (u *ui) handleStatusPreferenceClicks(gtx layout.Context) { + for u.setStatusBannerShort.Clicked(gtx) { + u.setStatusBannerTTL(2 * time.Second) + } + for u.setStatusBannerStandard.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerDuration) + } + for u.setStatusBannerLong.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerLong) + } +} + +func (u *ui) handleAutofillPreferenceClicks(gtx layout.Context) { + for u.showAllAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeAll) + } + for u.showApprovalAutofillOnly.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeApprovals) + } + for u.hideAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeSuppressed) + } + for u.showAutofillApprovalAsk.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk + u.saveUIPreferences() + } + for u.showAutofillApprovalAllow.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow + u.saveUIPreferences() + } + for u.showAutofillApprovalBlock.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock + u.saveUIPreferences() + } +} + +func (u *ui) handleAccessibilityClicks(gtx layout.Context) { + for u.settingsDensityDense.Clicked(gtx) { + u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense + _ = u.applySecuritySettingsLive() + } + for u.settingsDensityComfortable.Clicked(gtx) { + u.settingsDraft.Accessibility.DisplayDensity = displayDensityComfortable + _ = u.applySecuritySettingsLive() + } + for u.settingsContrastStandard.Clicked(gtx) { + u.settingsDraft.Accessibility.Contrast = contrastStandard + _ = u.applySecuritySettingsLive() + } + for u.settingsContrastHigh.Clicked(gtx) { + u.settingsDraft.Accessibility.Contrast = contrastHigh + _ = u.applySecuritySettingsLive() + } + for u.settingsReducedMotionOff.Clicked(gtx) { + u.settingsDraft.Accessibility.ReducedMotion = false + _ = u.applySecuritySettingsLive() + } + for u.settingsReducedMotionOn.Clicked(gtx) { + u.settingsDraft.Accessibility.ReducedMotion = true + _ = u.applySecuritySettingsLive() + } + for u.settingsKeyboardFocusStandard.Clicked(gtx) { + u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusStandard + _ = u.applySecuritySettingsLive() + } + for u.settingsKeyboardFocusProminent.Clicked(gtx) { + u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent + _ = u.applySecuritySettingsLive() + } +} + +func (u *ui) handleSettingsSyncDefaultClicks(gtx layout.Context) { + for u.showSettingsSyncLocal.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceLocal + _ = u.applySecuritySettingsLive() + } + for u.showSettingsSyncRemote.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceRemote + _ = u.applySecuritySettingsLive() + } + for u.showSettingsSyncPull.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPull + _ = u.applySecuritySettingsLive() + } + for u.showSettingsSyncPush.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPush + _ = u.applySecuritySettingsLive() + } +} + +func (u *ui) handleSectionAndSyncClicks(gtx layout.Context) { + u.handleSectionClicks(gtx) + u.handleLifecycleModeClicks(gtx) + u.handleSyncChoiceClicks(gtx) + u.handleRemoteBindingClicks(gtx) +} + +func (u *ui) handleSectionClicks(gtx layout.Context) { + for u.showEntries.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showEntriesSection() + } + for u.showTemplates.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showTemplatesSection() + } + for u.showRecycle.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showRecycleBinSection() + } + for u.showAPITokens.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAPITokensSection() + } + for u.showAPIAudit.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAPIAuditSection() + } + for u.showAbout.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAboutSection() + } +} + +func (u *ui) handleLifecycleModeClicks(gtx layout.Context) { + for u.showLocalLifecycle.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleMode = "local" + u.requestMasterPassFocus = true + } + } + for u.showRemoteLifecycle.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleMode = "remote" + u.selectedRemoteConnection = false + u.requestMasterPassFocus = true + } + } +} + +func (u *ui) handleSyncChoiceClicks(gtx layout.Context) { + for u.showSyncLocal.Clicked(gtx) { + u.syncSourceMode = syncSourceLocal + } + for u.showSyncRemote.Clicked(gtx) { + u.syncSourceMode = syncSourceRemote + } + for u.showSyncPull.Clicked(gtx) { + u.syncDirection = syncDirectionPull + } + for u.showSyncPush.Clicked(gtx) { + u.syncDirection = syncDirectionPush + } +} + +func (u *ui) handleRemoteBindingClicks(gtx layout.Context) { + for u.useSavedAdvancedSyncRemote.Clicked(gtx) { + u.openRemoteSyncSetupDialog() + } + for u.openSelectedVaultRemote.Clicked(gtx) { + if !u.lifecycleBusy() { + u.startOpenRemoteAction() + } + } + for u.saveCurrentRemoteBinding.Clicked(gtx) { + u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) + } + for u.removeSelectedRemoteBinding.Clicked(gtx) { + u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction) + } + for u.shareCurrentVault.Clicked(gtx) { + u.runAction("share vault", u.shareCurrentVaultAction) + } +} + +func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { + u.handleApprovalClicks(gtx) + u.handleAPITokenClicks(gtx) + u.handleAPIPolicyClicks(gtx) +} + +func (u *ui) handleApprovalClicks(gtx layout.Context) { + for u.allowApproval.Clicked(gtx) { + u.runAction("allow API request", func() error { + outcome := apiapproval.OutcomeAllowOnce + if u.approvalPermanent.Value { + outcome = apiapproval.OutcomeAllowPermanent + } + err := u.resolvePendingApproval(outcome) + u.approvalPermanent.Value = false + return err + }) + } + for u.denyApproval.Clicked(gtx) { + u.runAction("deny API request", func() error { + outcome := apiapproval.OutcomeDenyOnce + if u.approvalPermanent.Value { + outcome = apiapproval.OutcomeDenyPermanent + } + err := u.resolvePendingApproval(outcome) + u.approvalPermanent.Value = false + return err + }) + } + for u.cancelApproval.Clicked(gtx) { + u.runAction("cancel API request", func() error { + err := u.resolvePendingApproval(apiapproval.OutcomeCancel) + u.approvalPermanent.Value = false + return err + }) + } +} + +func (u *ui) handleAPITokenClicks(gtx layout.Context) { + for u.issueAPIToken.Clicked(gtx) { + u.runAction("issue API token", u.issueAPITokenAction) + } + for u.saveAPIToken.Clicked(gtx) { + u.runAction("save API token", u.saveAPITokenAction) + } + for u.rotateAPIToken.Clicked(gtx) { + u.runAction("rotate API token", u.rotateAPITokenAction) + } + for u.disableAPIToken.Clicked(gtx) { + u.runAction("disable API token", u.disableAPITokenAction) + } + for u.revokeAPIToken.Clicked(gtx) { + u.runAction("revoke API token", u.revokeAPITokenAction) + } + for u.deleteAPIToken.Clicked(gtx) { + u.runAction("delete API token", u.deleteAPITokenAction) + } + for u.copyAPITokenSecret.Clicked(gtx) { + secret := u.apiTokenSecret + u.runAction("copy API token secret", func() error { + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("no API token secret to copy") + } + if u.clipboardWriter != nil { + return u.clipboardWriter.WriteText(secret) + } + return clipboard.WriteText(secret) + }) + } +} + +func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { + for u.addAPIPolicyRule.Clicked(gtx) { + u.runAction("add API policy rule", u.addAPIPolicyRuleAction) + } + for u.useCurrentGroupForPolicy.Clicked(gtx) { + u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction) + } + for u.useSelectedEntryForPolicy.Clicked(gtx) { + u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction) + } + for u.clearAPIPolicyTarget.Clicked(gtx) { + u.runAction("clear API policy target", u.clearAPIPolicyTargetAction) + } + for i := range u.apiPolicyRemoves { + for u.apiPolicyRemoves[i].Clicked(gtx) { + index := i + u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) + } + } +} + +func (u *ui) handleSelectionClicks(gtx layout.Context) { + u.handleFileSelectionClicks(gtx) + u.handleRecentSelectionClicks(gtx) + u.handleRemoteSelectionClicks(gtx) + u.handleClearSelectionClicks(gtx) +} + +func (u *ui) handleFileSelectionClicks(gtx layout.Context) { + for u.pickVaultPath.Clicked(gtx) { + if !u.lifecycleBusy() { + u.startChooseVaultPathAction() + } + } + for u.importSharedVault.Clicked(gtx) { + if !u.lifecycleBusy() { + u.startImportSharedVaultAction() + } + } + for u.pickKeyFile.Clicked(gtx) { + if !u.lifecycleBusy() { + u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) + } + } + for u.pickSyncLocalPath.Clicked(gtx) { + u.startChooseSyncLocalSourceAction() + } +} + +func (u *ui) handleRecentSelectionClicks(gtx layout.Context) { + for i := range u.recentVaultClicks { + for u.recentVaultClicks[i].Clicked(gtx) { + if !u.lifecycleBusy() && i < len(u.recentVaults) { + u.lifecycleMode = "local" + u.vaultPath.SetText(u.recentVaults[i]) + u.requestMasterPassFocus = true + } + } + } + for i := range u.recentRemoteClicks { + for u.recentRemoteClicks[i].Clicked(gtx) { + if !u.lifecycleBusy() && i < len(u.recentRemotes) { + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(u.recentRemotes[i]) + u.requestMasterPassFocus = true + } + } + } +} + +func (u *ui) handleRemoteSelectionClicks(gtx layout.Context) { + for i := range u.vaultRemoteProfileClicks { + for u.vaultRemoteProfileClicks[i].Clicked(gtx) { + profiles := u.availableRemoteProfiles() + if i < len(profiles) { + u.selectVaultRemoteProfile(profiles[i].ID) + } + } + } + for i := range u.vaultRemoteCredentialClicks { + for u.vaultRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.availableRemoteCredentialEntries() + if i < len(entries) { + u.selectVaultRemoteCredentialEntry(entries[i].ID) + } + } + } + for i := range u.syncRemoteCredentialClicks { + for u.syncRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.matchingAdvancedSyncRemoteCredentialEntries() + if i < len(entries) { + u.applyAdvancedSyncRemoteCredentialEntry(entries[i]) + } + } + } +} + +func (u *ui) handleClearSelectionClicks(gtx layout.Context) { + for u.clearVaultSelection.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + if u.shouldUseLockedSinglePane() { + u.switchToLifecycleSelection("local") + continue + } + u.vaultPath.SetText("") + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.requestMasterPassFocus = true + } + for u.clearRemoteSelection.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + if u.shouldUseLockedSinglePane() { + u.switchToLifecycleSelection("remote") + continue + } + u.selectedRemoteConnection = false + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.requestMasterPassFocus = true + } +} + +func (u *ui) handleVaultAndEntryClicks(gtx layout.Context) { + u.handleEntryEditorClicks(gtx) + u.handleEntryMutationClicks(gtx) + u.handleAttachmentAndCopyClicks(gtx) +} + +func (u *ui) handleEntryEditorClicks(gtx layout.Context) { + for u.editEntry.Clicked(gtx) { + u.editingEntry = true + u.loadSelectedEntryIntoEditor() + } + for u.cancelEdit.Clicked(gtx) { + u.editingEntry = false + u.loadSelectedEntryIntoEditor() + } + for u.addEntry.Clicked(gtx) { + u.state.BeginNewEntry() + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) + u.editingEntry = true + } +} + +func (u *ui) handleEntryMutationClicks(gtx layout.Context) { + for u.saveEntry.Clicked(gtx) { + u.runAction("save entry", u.saveEntryAction) + } + for u.duplicateEntry.Clicked(gtx) { + u.runAction("duplicate entry", u.duplicateSelectedEntryAction) + } + for u.deleteEntry.Clicked(gtx) { + u.runAction("delete entry", u.deleteSelectedEntryAction) + } + for u.restoreEntry.Clicked(gtx) { + u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) + } + for u.saveTemplate.Clicked(gtx) { + u.runAction("save template", u.saveTemplateAction) + } + for u.deleteTemplate.Clicked(gtx) { + u.runAction("delete template", u.deleteSelectedTemplateAction) + } + for u.instantiateTemplate.Clicked(gtx) { + u.runAction("instantiate template", u.instantiateSelectedTemplateAction) + } +} + +func (u *ui) handleAttachmentAndCopyClicks(gtx layout.Context) { + for u.addAttachment.Clicked(gtx) { + u.runAction("add attachment", u.addAttachmentAction) + } + for u.replaceAttachment.Clicked(gtx) { + u.runAction("replace attachment", u.replaceAttachmentAction) + } + for u.removeAttachment.Clicked(gtx) { + u.runAction("remove attachment", u.removeAttachmentAction) + } + for u.exportAttachment.Clicked(gtx) { + u.runAction("export attachment", u.exportAttachmentAction) + } + for u.copyUser.Clicked(gtx) { + u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) + } + for u.copyPass.Clicked(gtx) { + u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) + } + for u.copyURL.Clicked(gtx) { + u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) + } + for u.generatePassword.Clicked(gtx) { + u.runAction("generate password", u.generatePasswordAction) + } + for u.restoreHistory.Clicked(gtx) { + u.runAction("restore history", u.restoreSelectedHistoryAction) + } +} + +func (u *ui) handleGroupClicks(gtx layout.Context) { + for u.createGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("create group", u.createGroupAction) + } + for u.moveGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("move group", u.moveCurrentGroupAction) + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.syncedPath = append([]string(nil), u.state.CurrentPath...) + u.filter() + } + for u.toggleGroupControls.Clicked(gtx) { + u.groupControlsHidden = !u.groupControlsHidden + u.saveUIPreferences() + } + for u.toggleHistory.Clicked(gtx) { + u.historyHidden = !u.historyHidden + u.saveUIPreferences() + } + for u.renameGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("rename group", u.renameGroupAction) + } + for u.deleteGroup.Clicked(gtx) { + u.armDeleteCurrentGroupAction() + } + for u.confirmDeleteGroup.Clicked(gtx) { + u.runAction("delete group", u.deleteCurrentGroupAction) + u.clearDeleteGroupConfirmation() + } + for u.cancelDeleteGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + } +} + +func (u *ui) handleInputUpdates(gtx layout.Context) { + if u.securityDialogOpen { + if _, changed := u.securityCipher.Update(gtx); changed { + _ = u.applySecuritySettingsLive() + } + if _, changed := u.securityKDF.Update(gtx); changed { + _ = u.applySecuritySettingsLive() + } + if _, changed := u.autofillBrowserAllowlist.Update(gtx); changed { + u.saveUIPreferences() + } + if _, changed := u.autofillAppAllowlist.Update(gtx); changed { + u.saveUIPreferences() + } + if _, changed := u.autofillPackageRules.Update(gtx); changed { + u.saveUIPreferences() + } + } + for u.togglePassword.Clicked(gtx) { + u.showPassword = !u.showPassword + } + for u.togglePasswordInline.Clicked(gtx) { + u.showPassword = !u.showPassword + } + for u.toggleSyncPassword.Clicked(gtx) { + u.showSyncPassword = !u.showSyncPassword + if u.showSyncPassword { + u.syncRemotePassword.Mask = 0 + } else { + u.syncRemotePassword.Mask = '•' + } + } + if _, changed := u.search.Update(gtx); changed { + u.filter() + } +} + +func (u *ui) mainFrame(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.header), + layout.Rigid(u.bannerRow), + layout.Rigid(u.autofillStatusRow), + layout.Flexed(1, u.primaryContent), + ) +} + +func (u *ui) bannerRow(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind == bannerNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.banner), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + ) +} + +func (u *ui) autofillStatusRow(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind != bannerNone || u.autofillStatusSurface().Kind == autofillStatusNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(u.autofillStatusCard), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + ) +} + +func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { + switch { + case u.shouldShowLifecycleSetup(): + return u.lifecycleScreen(gtx) + case u.shouldUseLockedSinglePane(): + return u.detailPanel(gtx) + case u.usesCompactViewport(): + return u.compactPrimaryContent(gtx) + default: + return u.widePrimaryContent(gtx) + } +} + +func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { + u.phoneSpan = gtx.Constraints.Max.Y + listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) + if min := gtx.Dp(unit.Dp(180)); listHeight < min { + listHeight = min + } + if max := gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)); listHeight > max { + listHeight = max + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.Y = listHeight + gtx.Constraints.Max.Y = listHeight + return u.listPanel(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.phoneSlider), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + u.compactDetailFlexChild(), + ) +} + +func (u *ui) compactDetailFlexChild() layout.FlexChild { + if u.shouldUseCompactPhoneDetailPane() { + return layout.Rigid(u.detailPanel) + } + return layout.Flexed(1, u.detailPanel) +} + +func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(0.38, u.listPanel), + layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), + layout.Flexed(0.62, u.detailPanel), + ) +} + +func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.syncDialogOpen { + return layout.Dimensions{} + } + return u.syncDialog(gtx) +} + +func (u *ui) securityDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.securityDialogOpen { + return layout.Dimensions{} + } + return u.securityDialog(gtx) +} + +func (u *ui) remotePrefsDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.remotePrefsDialogOpen { + return layout.Dimensions{} + } + return u.remotePrefsDialog(gtx) +} + +func (u *ui) approvalDialogOverlay(gtx layout.Context) layout.Dimensions { + if _, ok := u.pendingApproval(); !ok { + return layout.Dimensions{} + } + return u.approvalDialog(gtx) +} diff --git a/internal/appui/ui_layout_header.go b/internal/appui/ui_layout_header.go index 31fdf51..1c55acd 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/ui_layout_header.go @@ -10,7 +10,7 @@ import ( "gioui.org/widget" "gioui.org/widget/material" "git.julianfamily.org/keepassgo/internal/appui/actions" - appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" "git.julianfamily.org/keepassgo/internal/vault" ) @@ -46,7 +46,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) - metrics := appuilayout.HeaderActionMetrics{Spacing: spacing} + metrics := headerlayout.ActionMetrics{Spacing: spacing} row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -75,7 +75,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } - surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} + surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) rowCall.Add(gtx.Ops) @@ -478,17 +478,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } gtx.Constraints.Min = gtx.Constraints.Max contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := appuilayout.DropdownSurface{ + surface := headerlayout.DropdownSurface{ ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), LeftInset: contentInsetPx, TopInset: contentInsetPx, } if u.syncMenuVisibleOnPhone() { - surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) } if u.mainMenuVisibleOnPhone() { - surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) } return layout.Dimensions{Size: gtx.Constraints.Max} } diff --git a/internal/appui/ui_recent_state.go b/internal/appui/ui_recent_state.go new file mode 100644 index 0000000..3c1948f --- /dev/null +++ b/internal/appui/ui_recent_state.go @@ -0,0 +1,1787 @@ +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) + if len(groups) != 1 { + return "" + } + return groups[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." +} diff --git a/internal/appui/ui_runtime.go b/internal/appui/ui_runtime.go new file mode 100644 index 0000000..450d2fa --- /dev/null +++ b/internal/appui/ui_runtime.go @@ -0,0 +1,191 @@ +package appui + +import ( + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "gioui.org/app" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/x/explorer" + "git.julianfamily.org/keepassgo/internal/api" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/appui/platform" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" +) + +func Main() { + mode := flag.String("mode", "", "window mode: desktop or phone") + stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") + grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") + flag.Parse() + + resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) + resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") + resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) + + width := unit.Dp(1180) + height := unit.Dp(760) + if strings.EqualFold(resolvedMode, "phone") { + width = unit.Dp(412) + height = unit.Dp(915) + } + + go func() { + w := new(app.Window) + options := []app.Option{app.Title(productName)} + if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) { + options = append(options, app.Size(width, height)) + } + w.Option(options...) + if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { + panic(err) + } + if !strings.EqualFold(runtime.GOOS, "android") { + os.Exit(0) + } + }() + app.Main() +} + +func defaultGRPCAddr(goos string) string { + if strings.EqualFold(strings.TrimSpace(goos), "android") { + return "off" + } + return "127.0.0.1:47777" +} + +func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { + var ops op.Ops + manager := &session.Manager{} + ui := newUIWithSession(mode, manager, paths) + ui.fileExplorer = explorer.NewExplorer(w) + ui.invalidate = w.Invalidate + ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate) + host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) + if err != nil { + ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) + } else if host != nil { + ui.apiHost = host + ui.auditLog = host.Server().AuditLog() + ui.grpcAddress = host.Address() + ui.state.Approvals = &uiApprovalManager{server: host.Server()} + defer func() { _ = host.Stop() }() + } + for { + e := w.Event() + ui.fileExplorer.ListenEvents(e) + switch e := e.(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + ui.processBackgroundActions() + ui.layout(gtx) + platform.ProcessClipboardWrites(gtx, ui.clipboardWriter) + e.Frame(gtx.Ops) + } + } +} + +type uiApprovalManager struct { + server *api.Server +} + +func (m *uiApprovalManager) Pending() []apiapproval.Request { + if m == nil || m.server == nil { + return nil + } + return m.server.ApprovalBroker().Pending() +} + +func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { + if m == nil || m.server == nil { + return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") + } + return m.server.ResolveApproval(id, outcome) +} + +type uiSession struct { + model vault.Model + locked bool +} + +func (s *uiSession) HasVault() bool { + return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked +} + +func (s *uiSession) IsLocked() bool { + return s.locked +} + +func (s *uiSession) IsRemote() bool { + return false +} + +func (s *uiSession) Current() (vault.Model, error) { + if s.locked { + return vault.Model{}, session.ErrLocked + } + return s.model, nil +} + +func (s *uiSession) Replace(model vault.Model) { + s.model = model +} + +func (s *uiSession) Lock() error { + s.locked = true + return nil +} + +func (s *uiSession) Unlock(vault.MasterKey) error { + if !s.locked { + return nil + } + s.locked = false + return nil +} + +func pickExistingFile() (string, error) { + if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil { + return path, nil + } + if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil { + return path, nil + } + return "", fmt.Errorf("no supported file picker found; install kdialog or zenity") +} + +func runFilePicker(name string, args ...string) (string, error) { + if _, err := exec.LookPath(name); err != nil { + return "", err + } + cmd := exec.Command(name, args...) + output, err := cmd.Output() + if err != nil { + return "", err + } + return parsePickedFilePath(output) +} + +func parsePickedFilePath(output []byte) (string, error) { + lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") { + return line, nil + } + } + return "", fmt.Errorf("file picker did not return a path") +}