diff --git a/main.go b/main.go index d277d1c..03f07a4 100644 --- a/main.go +++ b/main.go @@ -321,6 +321,14 @@ type ui struct { apiHost *api.Host auditLog *apiaudit.Log grpcAddress string + backgroundResults chan backgroundActionResult + invalidate func() +} + +type backgroundActionResult struct { + label string + apply func() error + err error } var ( @@ -431,6 +439,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, apiPolicyGroupScope: true, + backgroundResults: make(chan backgroundActionResult, 8), } u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true @@ -731,6 +740,42 @@ func (u *ui) openVaultAction() error { 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) + return + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) + return + } + 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.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + }) +} + func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err @@ -779,6 +824,48 @@ func (u *ui) openRemoteAction() error { 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) + 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.runBackgroundAction("open remote vault", func() (func() error, error) { + prepared, err := session.PrepareRemoteOpen(client, remotePath, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedRemoteOpen(prepared) + u.noteRecentRemote( + strings.TrimSpace(u.remoteBaseURL.Text()), + remotePath, + strings.TrimSpace(u.remoteUsername.Text()), + u.remotePassword.Text(), + u.rememberRemoteAuth.Value, + ) + 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 { @@ -808,6 +895,36 @@ func (u *ui) unlockAction() error { 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) + 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() @@ -1446,6 +1563,9 @@ func (u *ui) armDeleteCurrentGroupAction() { } func (u *ui) runAction(label string, action func() error) { + if strings.TrimSpace(u.loadingMessage) != "" { + return + } u.loadingMessage = actionLoadingLabel(label) if err := action(); err != nil { u.loadingMessage = "" @@ -1466,6 +1586,61 @@ func (u *ui) runAction(label string, action func() error) { u.statusExpiresAt = u.now().Add(statusBannerDuration) } +func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { + if strings.TrimSpace(u.loadingMessage) != "" { + return + } + u.loadingMessage = actionLoadingLabel(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} + if u.invalidate != nil { + u.invalidate() + } + }() +} + +func (u *ui) applyBackgroundResult(result backgroundActionResult) { + u.loadingMessage = "" + if result.err != nil { + u.state.ErrorMessage = u.describeActionError(result.label, result.err) + 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) + 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.state.StatusMessage = result.label + " complete" + u.statusExpiresAt = u.now().Add(statusBannerDuration) +} + +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 @@ -1719,7 +1894,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("create vault", u.createVaultAction) } for u.openVault.Clicked(gtx) { - u.runAction("open vault", u.openVaultAction) + u.startOpenVaultAction() } for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) @@ -1728,7 +1903,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("save-as vault", u.saveAsAction) } for u.openRemote.Clicked(gtx) { - u.runAction("open remote vault", u.openRemoteAction) + u.startOpenRemoteAction() } for u.changeMasterKey.Clicked(gtx) { u.runAction("change master key", u.changeMasterKeyAction) @@ -1760,7 +1935,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("save security settings", u.saveSecuritySettingsAction) } for u.unlockVault.Clicked(gtx) { - u.runAction("unlock vault", u.unlockAction) + u.startUnlockAction() } for u.showEntries.Clicked(gtx) { u.clearDeleteGroupConfirmation() @@ -3661,6 +3836,7 @@ 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.invalidate = 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) @@ -3678,6 +3854,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) + ui.processBackgroundActions() ui.layout(gtx) e.Frame(gtx.Ops) } diff --git a/main_test.go b/main_test.go index e4fdeb3..eda1f82 100644 --- a/main_test.go +++ b/main_test.go @@ -44,6 +44,17 @@ func TestMain(m *testing.M) { os.Exit(code) } +func waitForBackgroundResult(t *testing.T, u *ui) backgroundActionResult { + t.Helper() + select { + case result := <-u.backgroundResults: + return result + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for background action result") + return backgroundActionResult{} + } +} + func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { t.Parallel() @@ -150,6 +161,42 @@ func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { } } +func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + started := make(chan struct{}) + release := make(chan struct{}) + runs := 0 + + u.runBackgroundAction("open vault", func() (func() error, error) { + runs++ + close(started) + <-release + return func() error { return nil }, nil + }) + <-started + + u.runBackgroundAction("open vault", func() (func() error, error) { + runs++ + return func() error { return nil }, nil + }) + + if runs != 1 { + t.Fatalf("background runs = %d, want 1", runs) + } + if got := u.loadingMessage; got != "Open vault..." { + t.Fatalf("loadingMessage = %q, want %q", got, "Open vault...") + } + + close(release) + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + if got := u.loadingMessage; got != "" { + t.Fatalf("loadingMessage after apply = %q, want empty", got) + } +} + func TestUIChildGroupsComeFromVaultModel(t *testing.T) { t.Parallel() @@ -867,6 +914,66 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { } } +func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(key.Password) + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vaults/main.kdbx") + + u.startOpenRemoteAction() + + if got := u.loadingMessage; got != "Open remote vault..." { + t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...") + } + if manager.HasVault() { + t.Fatal("manager.HasVault() = true before remote result applied, want false") + } + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.loadingMessage; got != "" { + t.Fatalf("loadingMessage after apply = %q, want empty", got) + } + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if !manager.HasVault() { + t.Fatal("manager.HasVault() = false after remote result applied, want true") + } + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("filteredTitles() = %v, want [Vault Console]", got) + } +} + func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { t.Parallel() @@ -1016,6 +1123,88 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) { } } +func TestUIStartOpenVaultActionAppliesResultOnMainThread(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "vault.kdbx") + writeKDBXMainTestFile(t, path, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(path) + + u.startOpenVaultAction() + + if got := u.loadingMessage; got != "Open vault..." { + t.Fatalf("loadingMessage after start = %q, want %q", got, "Open vault...") + } + if manager.HasVault() { + t.Fatal("manager.HasVault() = true before background result applied, want false") + } + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.loadingMessage; got != "" { + t.Fatalf("loadingMessage after apply = %q, want empty", got) + } + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if !manager.HasVault() { + t.Fatal("manager.HasVault() = false after background result applied, want true") + } + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("filteredTitles() = %v, want [Vault Console]", got) + } +} + +func TestUIStartUnlockActionAppliesResultOnMainThread(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(key.Password) + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + if err := u.lockAction(); err != nil { + t.Fatalf("lockAction() error = %v", err) + } + + u.masterPassword.SetText(key.Password) + u.startUnlockAction() + + if got := u.loadingMessage; got != "Unlock vault..." { + t.Fatalf("loadingMessage after start = %q, want %q", got, "Unlock vault...") + } + if !manager.IsLocked() { + t.Fatal("manager.IsLocked() = false before background result applied, want true") + } + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.loadingMessage; got != "" { + t.Fatalf("loadingMessage after apply = %q, want empty", got) + } + if manager.IsLocked() { + t.Fatal("manager.IsLocked() = true after background result applied, want false") + } +} + func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) { t.Parallel() @@ -3022,12 +3211,23 @@ func TestEnterOnLockedScreenDefaultsToUnlockVault(t *testing.T) { if !handled { t.Fatal("handleKeyPress(Return) = false, want true while locked") } - if u.isVaultLocked() { - t.Fatal("isVaultLocked() = true, want false after unlock") - } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword after unlock = %q, want empty", got) } + if !u.isVaultLocked() { + t.Fatal("isVaultLocked() = false before background apply, want still locked") + } + result := waitForBackgroundResult(t, u) + if err := result.err; err != nil { + t.Fatalf("background unlock prepare error = %v", err) + } + u.applyBackgroundResult(result) + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("state.ErrorMessage after unlock apply = %q, want empty", got) + } + if u.isVaultLocked() { + t.Fatal("isVaultLocked() = true, want false after unlock apply") + } } func TestUILockedVaultUsesSingleUnlockPaneAndOmitsSearchFocus(t *testing.T) { diff --git a/session/session.go b/session/session.go index 7fdccc7..00fd383 100644 --- a/session/session.go +++ b/session/session.go @@ -32,6 +32,33 @@ type Manager struct { remoteVersion webdav.Version } +type PreparedLocalOpen struct { + Model vault.Model + Config *vault.KDBXConfig + Path string + Key vault.MasterKey + Encoded []byte + VaultRoot string +} + +type PreparedRemoteOpen struct { + Model vault.Model + Config *vault.KDBXConfig + Client webdav.Client + Path string + Key vault.MasterKey + Encoded []byte + VaultRoot string + RemoteVersion webdav.Version +} + +type PreparedUnlock struct { + Model vault.Model + Config *vault.KDBXConfig + Key vault.MasterKey + VaultRoot string +} + func (m *Manager) SecuritySettings() vault.SecuritySettings { return vault.DetectSecuritySettings(m.config) } @@ -65,6 +92,10 @@ func (m *Manager) HasVault() bool { return len(m.encoded) > 0 || m.path != "" || m.remotePath != "" } +func (m *Manager) EncodedBytes() []byte { + return append([]byte(nil), m.encoded...) +} + func (m *Manager) IsLocked() bool { return m.locked } @@ -74,23 +105,11 @@ func (m *Manager) IsRemote() bool { } func (m *Manager) Open(path string, key vault.MasterKey) error { - content, err := os.ReadFile(path) + prepared, err := PrepareLocalOpen(path, key) if err != nil { - return fmt.Errorf("read %s: %w", path, err) + return err } - - model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) - if err != nil { - return fmt.Errorf("open %s: %w", path, err) - } - - m.model = model - m.config = config - m.path = path - m.key = key - m.vaultRoot = detectSingleVaultRoot(model) - m.encoded = content - m.locked = false + m.ApplyPreparedLocalOpen(prepared) return nil } @@ -107,25 +126,11 @@ func (m *Manager) Save() error { } func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error { - content, version, err := client.Open(path) + prepared, err := PrepareRemoteOpen(client, path, key) if err != nil { - return fmt.Errorf("open remote %s: %w", path, err) + return err } - - model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) - if err != nil { - return fmt.Errorf("decode remote %s: %w", path, err) - } - - m.model = model - m.config = config - m.key = key - m.vaultRoot = detectSingleVaultRoot(model) - m.encoded = content - m.locked = false - m.remoteClient = &client - m.remotePath = path - m.remoteVersion = version + m.ApplyPreparedRemoteOpen(prepared) return nil } @@ -265,19 +270,101 @@ func (m *Manager) Lock() error { } func (m *Manager) Unlock(key vault.MasterKey) error { - model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key) + prepared, err := PrepareUnlock(m.encoded, key) if err != nil { - return fmt.Errorf("unlock vault: %w", err) + return err } - - m.model = model - m.config = config - m.key = key - m.vaultRoot = detectSingleVaultRoot(model) - m.locked = false + m.ApplyPreparedUnlock(prepared) return nil } +func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) { + content, err := os.ReadFile(path) + if err != nil { + return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err) + } + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err) + } + return PreparedLocalOpen{ + Model: model, + Config: config, + Path: path, + Key: key, + Encoded: content, + VaultRoot: detectSingleVaultRoot(model), + }, nil +} + +func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) { + content, version, err := client.Open(path) + if err != nil { + return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err) + } + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err) + } + return PreparedRemoteOpen{ + Model: model, + Config: config, + Client: client, + Path: path, + Key: key, + Encoded: content, + VaultRoot: detectSingleVaultRoot(model), + RemoteVersion: version, + }, nil +} + +func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) { + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key) + if err != nil { + return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err) + } + return PreparedUnlock{ + Model: model, + Config: config, + Key: key, + VaultRoot: detectSingleVaultRoot(model), + }, nil +} + +func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) { + m.model = prepared.Model + m.config = prepared.Config + m.path = prepared.Path + m.key = prepared.Key + m.vaultRoot = prepared.VaultRoot + m.encoded = prepared.Encoded + m.locked = false + m.remoteClient = nil + m.remotePath = "" + m.remoteVersion = webdav.Version{} +} + +func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { + m.model = prepared.Model + m.config = prepared.Config + m.key = prepared.Key + m.vaultRoot = prepared.VaultRoot + m.encoded = prepared.Encoded + m.locked = false + m.remoteClient = &prepared.Client + m.remotePath = prepared.Path + m.remoteVersion = prepared.RemoteVersion + m.path = "" +} + +func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { + m.model = prepared.Model + m.config = prepared.Config + m.key = prepared.Key + m.vaultRoot = prepared.VaultRoot + m.locked = false +} + func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { var ( model vault.Model diff --git a/ui_keyboard.go b/ui_keyboard.go index 788c97a..9dfdc26 100644 --- a/ui_keyboard.go +++ b/ui_keyboard.go @@ -46,14 +46,14 @@ func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool { return true } if u.isVaultLocked() && name == key.NameReturn { - u.runAction("unlock vault", u.unlockAction) + u.startUnlockAction() return true } if u.shouldShowLifecycleSetup() && name == key.NameReturn { if u.lifecycleMode == "remote" { - u.runAction("open remote vault", u.openRemoteAction) + u.startOpenRemoteAction() } else { - u.runAction("open vault", u.openVaultAction) + u.startOpenVaultAction() } return true }