diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index e40fce5c3a..cc2c267019 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,8 +1,8 @@ { "schema": 1, "version": "0.5.5", - "base_sha": "4a2305170eef565bf1836e2859247e67c030f8af", + "base_sha": "25a9cf1be6d245fbd7373cb1160dbc790baf5bd5", "previous_tag": "desktop-v0.5.4", "tag": "desktop-v0.5.5", - "commit_count": 41 + "commit_count": 44 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 962b2ce29c..171f260d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Desktop and shared changes +- feat: paste composer text without formatting ([#4801](https://github.com/block/buzz/pull/4801)) ([`25a9cf1be6d245fbd7373cb1160dbc790baf5bd5`](https://github.com/block/buzz/commit/25a9cf1be6d245fbd7373cb1160dbc790baf5bd5)) +- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4808](https://github.com/block/buzz/pull/4808)) ([`79c52166cfe6b6d36bdc7686f943595c74e2f578`](https://github.com/block/buzz/commit/79c52166cfe6b6d36bdc7686f943595c74e2f578)) +- chore(release): release Buzz Desktop version 0.5.5 ([#4800](https://github.com/block/buzz/pull/4800)) ([`a0ed13de14ee64dd90c32335790f7d3b4e94330d`](https://github.com/block/buzz/commit/a0ed13de14ee64dd90c32335790f7d3b4e94330d)) - fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) - Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) - feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) diff --git a/desktop/src-tauri/src/commands/clipboard.rs b/desktop/src-tauri/src/commands/clipboard.rs index b4fe072ef8..c904e4a2d9 100644 --- a/desktop/src-tauri/src/commands/clipboard.rs +++ b/desktop/src-tauri/src/commands/clipboard.rs @@ -34,3 +34,22 @@ pub fn with_clipboard( operation(stored.as_mut().expect("clipboard initialized")) .map_err(|e| format!("clipboard error: {e}")) } + +/// Read plain text from the system clipboard through the native shell. +/// +/// Browser clipboard reads are permission-gated or unavailable in embedded +/// webviews. Arboard provides one consistent path across WKWebView, WebView2, +/// and WebKitGTK. The operation runs on the main thread for macOS/AppKit safety. +#[tauri::command] +pub async fn read_clipboard_text(app: tauri::AppHandle) -> Result { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + let clipboard_app = app.clone(); + app.run_on_main_thread(move || { + let result = with_clipboard(&clipboard_app, arboard::Clipboard::get_text); + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 98720cd2c5..a63514dca1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -724,6 +724,7 @@ pub fn run() { fetch_media_bytes, copy_image_to_clipboard, copy_text_to_clipboard, + read_clipboard_text, fetch_snapshot_bytes, relay_requires_membership, list_relay_members, diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 95225b8df0..1d85941fde 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -358,15 +358,12 @@ fn hydrate_keys(records: &mut [ManagedAgentRecord]) { // Without this guard `load_managed_agents_at` blocks on a macOS Security // daemon IPC call (`SecKeychainFindGenericPassword`) which hangs in // headless test environments. - #[cfg(test)] - { - let _ = records; - return; + #[cfg(not(test))] + if let Some(store) = agent_secret_store() { + hydrate_keys_with(store, records); } - let Some(store) = agent_secret_store() else { - return; - }; - hydrate_keys_with(store, records); + #[cfg(test)] + let _ = records; } /// Testable core of [`hydrate_keys`], generic over the [`KeyStore`] seam. @@ -544,16 +541,12 @@ fn persist_agent_keys(records: &mut [ManagedAgentRecord]) { // empty `private_key_nsec` where the keyring write is a no-op anyway. // Without this guard `save_managed_agents_at` blocks on a macOS Security // daemon IPC write call in headless test environments. - #[cfg(test)] - { - let _ = records; - return; + #[cfg(not(test))] + if let Some(store) = agent_secret_store() { + persist_agent_keys_with(store, records); } - let Some(store) = agent_secret_store() else { - // No keyring backend: keys stay inline. - return; - }; - persist_agent_keys_with(store, records); + #[cfg(test)] + let _ = records; } /// Testable core of [`persist_agent_keys`], generic over the [`KeyStore`] seam. diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4fee2db46f..c761081cc3 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -9,6 +9,7 @@ import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; import { Plugin, Selection, TextSelection } from "@tiptap/pm/state"; import type { ResolvedPos } from "@tiptap/pm/model"; +import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia"; import { hasPrimaryShortcutModifier, isMacPlatform, @@ -532,6 +533,39 @@ export function useRichTextEditor({ return true; } + // Cmd+Shift+V / Ctrl+Shift+V → paste the clipboard's plain-text + // representation. Embedded webviews permission-gate the browser + // clipboard API differently across operating systems, so packaged + // builds read through the native arboard command. Browser builds use + // navigator.clipboard as a fallback. Feed the result through + // ProseMirror's paste pipeline with clipboardData populated so its + // plain-text observers keep normal paste behavior. + if ( + event.key.toLowerCase() === "v" && + hasPrimaryShortcutModifier(event) && + event.shiftKey && + !event.altKey && + !event.repeat && + !event.isComposing + ) { + event.preventDefault(); + void readTextFromSystemClipboard() + .then((text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + view.pasteText( + text, + new ClipboardEvent("paste", { clipboardData }), + ); + }) + .catch(() => { + // The key is already consumed. Letting a delayed native paste + // race the asynchronous read could duplicate or unexpectedly + // format content. + }); + return true; + } + // ⌘K / Ctrl+K → link editor. The formatting toolbar has always // advertised this shortcut on its link button; bind it here so it // actually works. Kept alongside the ArrowUp handling below rather diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 60205a45fe..daedebde5c 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,4 +1,4 @@ -import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; +import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; function encodeRawIpcHeader(value: string): string { @@ -66,6 +66,23 @@ export async function fetchMediaBytes( return new Uint8Array(bytes); } +/** Read plain text without depending on embedded-webview clipboard grants. */ +export async function readTextFromSystemClipboard(): Promise { + // E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's + // `isTauri()` marker remains false. Exercise the packaged-app command path in + // that build so tests detect accidental regressions to permission-gated DOM + // clipboard reads. + if (isTauri() || import.meta.env.MODE === "e2e") { + return invokeTauri("read_clipboard_text"); + } + + const clipboard = navigator.clipboard; + if (!clipboard?.readText) { + throw new Error("Clipboard text reading is unavailable"); + } + return clipboard.readText(); +} + /** Write text through the native clipboard after an asynchronous workflow. */ export async function copyTextToSystemClipboard( text: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e6660a7297..128413f7b3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12531,6 +12531,8 @@ export function maybeInstallE2eTauriMocks() { case "copy_text_to_clipboard": await navigator.clipboard.writeText((payload as { text: string }).text); return; + case "read_clipboard_text": + return navigator.clipboard.readText(); case "get_event": return handleGetEvent( payload as Parameters[0], diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index b8ab46503d..202ec96869 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -177,6 +177,68 @@ async function applyCaretFormat( await page.getByRole("button", { name: label, exact: true }).click(); } +for (const platform of [ + { name: "macOS", navigatorPlatform: "MacIntel", shortcut: "Meta+Shift+V" }, + { + name: "Windows/Linux", + navigatorPlatform: "Win32", + shortcut: "Control+Shift+V", + }, +]) { + test(`pastes rich clipboard content without formatting on ${platform.name}`, async ({ + page, + }) => { + await page.addInitScript((navigatorPlatform) => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: navigatorPlatform, + }); + }, platform.navigatorPlatform); + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + await openGeneral(page); + + await page.evaluate(async () => { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob( + [ + '

Bold and linked

  • list item
', + ], + { type: "text/html" }, + ), + "text/plain": new Blob(["Bold and linked\nlist item"], { + type: "text/plain", + }), + }), + ]); + }); + + const input = page.getByTestId("message-input"); + await input.click(); + await page.keyboard.press(platform.shortcut); + + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("read_clipboard_text"); + await expect(input).toHaveText("Bold and linkedlist item"); + await expect(input.locator("strong, a, ul, li")).toHaveCount(0); + await expect(input.locator(":scope > p")).toHaveText([ + "Bold and linked", + "list item", + ]); + }); +} + for (const format of [ { label: "Code block", selector: "pre" }, { label: "Bullet list", selector: "ul" },