From 2df076f834f518e48087f47e1c2b5c9eaefa6a1d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 23 May 2026 19:59:13 +0200 Subject: [PATCH] fix: bound code-block clipboard copies --- src-tauri/src/commands/clipboard.rs | 207 +++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/system.rs | 123 +--------------- src/components/SingleEditorView.tsx | 16 +-- src/utils/clipboardText.test.ts | 66 +++++++++ src/utils/clipboardText.ts | 38 +++++ tests/smoke/fenced-code-copy.spec.ts | 15 +- 7 files changed, 324 insertions(+), 143 deletions(-) create mode 100644 src-tauri/src/commands/clipboard.rs create mode 100644 src/utils/clipboardText.test.ts create mode 100644 src/utils/clipboardText.ts diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs new file mode 100644 index 00000000..3e1c144c --- /dev/null +++ b/src-tauri/src/commands/clipboard.rs @@ -0,0 +1,207 @@ +#[cfg(desktop)] +use std::io::Write; +#[cfg(desktop)] +use std::process::{Child, Command, Output, Stdio}; +#[cfg(desktop)] +use std::thread; +#[cfg(desktop)] +use std::time::{Duration, Instant}; + +#[cfg(desktop)] +const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(desktop)] +const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25); + +#[cfg(target_os = "macos")] +fn clipboard_command() -> Command { + crate::hidden_command("pbcopy") +} + +#[cfg(target_os = "macos")] +fn clipboard_read_command() -> Command { + crate::hidden_command("pbpaste") +} + +#[cfg(target_os = "windows")] +fn clipboard_command() -> Command { + crate::hidden_command("clip.exe") +} + +#[cfg(target_os = "windows")] +fn clipboard_read_command() -> Command { + let mut command = crate::hidden_command("powershell.exe"); + command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]); + command +} + +#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] +fn clipboard_command() -> Command { + let mut command = crate::hidden_command("sh"); + command.args([ + "-c", + "if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi", + ]); + command +} + +#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] +fn clipboard_read_command() -> Command { + let mut command = crate::hidden_command("sh"); + command.args([ + "-c", + "if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi", + ]); + command +} + +#[cfg(desktop)] +fn clipboard_failure_message(stderr: &[u8]) -> String { + let message = String::from_utf8_lossy(stderr).trim().to_string(); + if message.is_empty() { + "Native clipboard command failed".to_string() + } else { + format!("Native clipboard command failed: {message}") + } +} + +#[cfg(desktop)] +fn clipboard_timeout_message(timeout: Duration) -> String { + format!( + "Native clipboard command timed out after {}ms", + timeout.as_millis() + ) +} + +#[cfg(desktop)] +fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result { + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_status)) => { + return child + .wait_with_output() + .map_err(|e| format!("Native clipboard command did not finish: {e}")); + } + Ok(None) if started.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(clipboard_timeout_message(timeout)); + } + Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL), + Err(e) => return Err(format!("Native clipboard command did not finish: {e}")), + } + } +} + +#[cfg(desktop)] +fn write_native_clipboard_with_timeout( + mut command: Command, + text: &str, + timeout: Duration, +) -> Result<(), String> { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to open native clipboard command: {e}"))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?; + stdin + .write_all(text.as_bytes()) + .map_err(|e| format!("Failed to write native clipboard text: {e}"))?; + drop(stdin); + + let output = wait_for_native_clipboard_output(child, timeout)?; + if output.status.success() { + Ok(()) + } else { + Err(clipboard_failure_message(&output.stderr)) + } +} + +#[cfg(desktop)] +fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> { + write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT) +} + +#[cfg(desktop)] +fn read_native_clipboard_with_timeout( + mut command: Command, + timeout: Duration, +) -> Result { + let child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to read native clipboard text: {e}"))?; + + let output = wait_for_native_clipboard_output(child, timeout)?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(clipboard_failure_message(&output.stderr)) + } +} + +#[cfg(desktop)] +fn read_native_clipboard(command: Command) -> Result { + read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text)) + .await + .map_err(|e| format!("Native clipboard task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn read_text_from_clipboard() -> Result { + tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command())) + .await + .map_err(|e| format!("Native clipboard task failed: {e}"))? +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> { + Err("Clipboard is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn read_text_from_clipboard() -> Result { + Err("Clipboard is not available on mobile".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(desktop, unix))] + #[test] + fn native_clipboard_write_times_out_slow_commands() { + let mut command = Command::new("sh"); + command.args(["-c", "cat >/dev/null; sleep 2"]); + + let started = Instant::now(); + let result = + write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50)); + + let error = result.expect_err("slow clipboard command should time out"); + assert!( + error.contains("timed out"), + "unexpected clipboard timeout error: {error}" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "clipboard timeout should return promptly" + ); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 75301fd3..55203d21 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ mod ai; +mod clipboard; mod delete; mod folders; mod git; @@ -13,6 +14,7 @@ mod version; use std::borrow::Cow; pub use ai::*; +pub use clipboard::*; pub use delete::*; pub use folders::*; pub use git::*; diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 1812e2ef..4753faa6 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -1,7 +1,5 @@ #[cfg(desktop)] -use std::io::Write; -#[cfg(desktop)] -use std::process::{Command, Stdio}; +use std::process::Command; #[cfg(desktop)] use crate::menu; @@ -142,113 +140,6 @@ pub async fn get_mcp_config_snippet(vault_path: String) -> Result Command { - crate::hidden_command("pbcopy") -} - -#[cfg(target_os = "macos")] -fn clipboard_read_command() -> Command { - crate::hidden_command("pbpaste") -} - -#[cfg(target_os = "windows")] -fn clipboard_command() -> Command { - crate::hidden_command("clip.exe") -} - -#[cfg(target_os = "windows")] -fn clipboard_read_command() -> Command { - let mut command = crate::hidden_command("powershell.exe"); - command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]); - command -} - -#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] -fn clipboard_command() -> Command { - let mut command = crate::hidden_command("sh"); - command.args([ - "-c", - "if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi", - ]); - command -} - -#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] -fn clipboard_read_command() -> Command { - let mut command = crate::hidden_command("sh"); - command.args([ - "-c", - "if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi", - ]); - command -} - -#[cfg(desktop)] -fn clipboard_failure_message(stderr: &[u8]) -> String { - let message = String::from_utf8_lossy(stderr).trim().to_string(); - if message.is_empty() { - "Native clipboard command failed".to_string() - } else { - format!("Native clipboard command failed: {message}") - } -} - -#[cfg(desktop)] -fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String> { - let mut child = command - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to open native clipboard command: {e}"))?; - - let mut stdin = child - .stdin - .take() - .ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?; - stdin - .write_all(text.as_bytes()) - .map_err(|e| format!("Failed to write native clipboard text: {e}"))?; - drop(stdin); - - let output = child - .wait_with_output() - .map_err(|e| format!("Native clipboard command did not finish: {e}"))?; - if output.status.success() { - Ok(()) - } else { - Err(clipboard_failure_message(&output.stderr)) - } -} - -#[cfg(desktop)] -fn read_native_clipboard(mut command: Command) -> Result { - let output = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to read native clipboard text: {e}"))?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - Err(clipboard_failure_message(&output.stderr)) - } -} - -#[cfg(desktop)] -#[tauri::command] -pub fn copy_text_to_clipboard(text: String) -> Result<(), String> { - write_native_clipboard(clipboard_command(), &text) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn read_text_from_clipboard() -> Result { - read_native_clipboard(clipboard_read_command()) -} - #[cfg(desktop)] #[tauri::command] pub async fn sync_mcp_bridge_vault( @@ -299,18 +190,6 @@ pub async fn get_mcp_config_snippet(_vault_path: String) -> Result Result<(), String> { - Err("Clipboard is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn read_text_from_clipboard() -> Result { - Err("Clipboard is not available on mobile".into()) -} - #[cfg(mobile)] #[tauri::command] pub async fn sync_mcp_bridge_vault( diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index bdfa7053..3dfff69f 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -1,6 +1,5 @@ import { ArrowSquareOut as ExternalLink, Copy } from '@phosphor-icons/react' import { Component, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { invoke } from '@tauri-apps/api/core' import { GridSuggestionMenuController, BlockNoteViewRaw, @@ -26,7 +25,7 @@ import { useEditorTheme } from '../hooks/useTheme' import { useImageDrop } from '../hooks/useImageDrop' import { useImageLightbox } from '../hooks/useImageLightbox' import { createTranslator, type AppLocale } from '../lib/i18n' -import { isTauri } from '../mock-tauri' +import { writeClipboardText } from '../utils/clipboardText' import { buildTypeEntryMap } from '../utils/typeColors' import { searchEmojis, type EmojiEntry } from '../utils/emoji' import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' @@ -476,19 +475,6 @@ function codeBlockText(codeBlock: HTMLElement): string { return codeElement?.textContent ?? '' } -async function writeClipboardText(text: string): Promise { - if (isTauri()) { - await invoke('copy_text_to_clipboard', { text }) - return - } - - if (!navigator.clipboard?.writeText) { - throw new Error('Clipboard API is unavailable') - } - - await navigator.clipboard.writeText(text) -} - type CodeBlockCopyTarget = { codeBlock: HTMLElement left: number diff --git a/src/utils/clipboardText.test.ts b/src/utils/clipboardText.test.ts new file mode 100644 index 00000000..d22295c0 --- /dev/null +++ b/src/utils/clipboardText.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { invoke } from '@tauri-apps/api/core' +import { writeClipboardText } from './clipboardText' + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +const originalClipboard = navigator.clipboard +const mockInvoke = vi.mocked(invoke) + +function setClipboard(writeText: ReturnType | undefined) { + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + value: writeText ? { writeText } : undefined, + }) +} + +describe('writeClipboardText', () => { + beforeEach(() => { + mockInvoke.mockReset() + ;(globalThis as { isTauri?: boolean }).isTauri = false + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + }) + + it('uses the Web Clipboard API when it is available', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + setClipboard(writeText) + + await writeClipboardText('copy me') + + expect(writeText).toHaveBeenCalledWith('copy me') + expect(mockInvoke).not.toHaveBeenCalled() + }) + + it('prefers the Web Clipboard API inside Tauri', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + setClipboard(writeText) + ;(globalThis as { isTauri?: boolean }).isTauri = true + + await writeClipboardText('copy me') + + expect(writeText).toHaveBeenCalledWith('copy me') + expect(mockInvoke).not.toHaveBeenCalled() + }) + + it('falls back to the native command when Web Clipboard copy fails inside Tauri', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('permission denied')) + setClipboard(writeText) + ;(globalThis as { isTauri?: boolean }).isTauri = true + mockInvoke.mockResolvedValue(undefined) + + await writeClipboardText('copy me') + + expect(mockInvoke).toHaveBeenCalledWith('copy_text_to_clipboard', { text: 'copy me' }) + }) + + it('reports unavailable clipboard support outside Tauri', async () => { + setClipboard(undefined) + + await expect(writeClipboardText('copy me')).rejects.toThrow('Clipboard API is unavailable') + }) +}) diff --git a/src/utils/clipboardText.ts b/src/utils/clipboardText.ts new file mode 100644 index 00000000..ec943939 --- /dev/null +++ b/src/utils/clipboardText.ts @@ -0,0 +1,38 @@ +import { invoke } from '@tauri-apps/api/core' +import { isTauri } from '../mock-tauri' + +const NATIVE_COPY_TEXT_COMMAND = 'copy_text_to_clipboard' + +type WebClipboardResult = + | { status: 'copied' } + | { status: 'failed'; error: unknown } + | { status: 'unavailable' } + +function hasWebClipboard(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function' +} + +async function writeWebClipboardText(text: string): Promise { + if (!hasWebClipboard()) return { status: 'unavailable' } + + try { + await navigator.clipboard.writeText(text) + return { status: 'copied' } + } catch (error) { + return { status: 'failed', error } + } +} + +export async function writeClipboardText(text: string): Promise { + const webResult = await writeWebClipboardText(text) + if (webResult.status === 'copied') return + + if (isTauri()) { + await invoke(NATIVE_COPY_TEXT_COMMAND, { text }) + return + } + + if (webResult.status === 'failed') throw webResult.error + + throw new Error('Clipboard API is unavailable') +} diff --git a/tests/smoke/fenced-code-copy.spec.ts b/tests/smoke/fenced-code-copy.spec.ts index 64327569..5c079c40 100644 --- a/tests/smoke/fenced-code-copy.spec.ts +++ b/tests/smoke/fenced-code-copy.spec.ts @@ -59,12 +59,15 @@ async function copySelectedText(page: Page) { return readClipboard(page) } -async function copyRichCodeBlockFromButton(page: Page, blockIndex: number) { +async function expectRichCodeBlockButtonCopy(page: Page, blockIndex: number, expectedText: string) { await clearClipboard(page) const codeBlock = page.locator('.bn-block-content[data-content-type="codeBlock"]').nth(blockIndex) + await expect(codeBlock).toBeVisible() await codeBlock.hover() - await page.getByRole('button', { name: 'Copy code to clipboard' }).click() - return readClipboard(page) + const copyButton = page.getByRole('button', { name: 'Copy code to clipboard' }) + await expect(copyButton).toBeVisible() + await copyButton.click() + await expect.poll(() => readClipboard(page)).toBe(expectedText) } async function selectRichCodeBlock(page: Page, blockIndex: number) { @@ -128,9 +131,9 @@ test.describe('Fenced code copy', () => { await noteItem.click() await expect(page.locator(RICH_CODE_SELECTOR)).toHaveCount(3, { timeout: 10_000 }) - await expect.poll(() => copyRichCodeBlockFromButton(page, 0)).toBe(JSON_SNIPPET) - await expect.poll(() => copyRichCodeBlockFromButton(page, 1)).toBe(TYPESCRIPT_SNIPPET) - await expect.poll(() => copyRichCodeBlockFromButton(page, 2)).toBe(CJK_SNIPPET) + await expectRichCodeBlockButtonCopy(page, 0, JSON_SNIPPET) + await expectRichCodeBlockButtonCopy(page, 1, TYPESCRIPT_SNIPPET) + await expectRichCodeBlockButtonCopy(page, 2, CJK_SNIPPET) await selectRichCodeBlock(page, 0) await expect.poll(() => copySelectedText(page)).toBe(JSON_SNIPPET)