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.adoptStateCurrentPath() 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.adoptStateCurrentPath() 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.adoptStateCurrentPath() 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.adoptStateCurrentPath() 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.adoptStateCurrentPath() 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.adoptStateCurrentPath() 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)) }