Skip to content

Commit 60baeba

Browse files
committed
Clipboard: Fix text clipboard in all windows
- Non-main windows (viewer, settings): `send_native_clipboard_action()` sends `copy:`/`cut:`/`paste:` through the responder chain via `NSApplication.sendAction:to:from:`, replicating what `PredefinedMenuItem` does internally - Main window text inputs: `read_clipboard_text` Rust command reads `NSPasteboard` directly, bypassing WebKit's `navigator.clipboard.readText()` permission popup - `edit.pasteAsMove` scoped to `FileScoped` (file-only, no text equivalent)
1 parent 6342d7b commit 60baeba

9 files changed

Lines changed: 125 additions & 24 deletions

File tree

apps/desktop/src-tauri/src/clipboard/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ mod state;
77
pub use state::{clear_cut_state, get_cut_state, set_cut_state};
88

99
#[cfg(target_os = "macos")]
10-
pub use pasteboard::{read_file_urls_from_clipboard, write_file_urls_to_clipboard};
10+
pub use pasteboard::{read_file_urls_from_clipboard, read_text_from_clipboard, write_file_urls_to_clipboard};

apps/desktop/src-tauri/src/clipboard/pasteboard.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,13 @@ pub fn read_file_urls_from_clipboard() -> Result<Vec<PathBuf>, String> {
107107

108108
Ok(paths)
109109
}
110+
111+
/// Reads plain text from the system pasteboard.
112+
///
113+
/// Used by the frontend to paste text into input fields without triggering
114+
/// WebKit's clipboard permission popup (which `navigator.clipboard.readText()` causes).
115+
pub fn read_text_from_clipboard() -> Option<String> {
116+
let pasteboard = NSPasteboard::generalPasteboard();
117+
let pasteboard_type = unsafe { NSPasteboardTypeString };
118+
pasteboard.stringForType(pasteboard_type).map(|s| s.to_string())
119+
}

apps/desktop/src-tauri/src/commands/clipboard.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,30 @@ pub async fn read_clipboard_files(app: tauri::AppHandle) -> Result<ClipboardRead
133133
Ok(ClipboardReadResult { paths, is_cut })
134134
}
135135

136+
/// Reads plain text from the system clipboard.
137+
///
138+
/// Used by the frontend to paste text into input fields. Going through Rust bypasses
139+
/// WebKit's `navigator.clipboard.readText()` permission popup.
140+
#[cfg(target_os = "macos")]
141+
#[tauri::command]
142+
pub async fn read_clipboard_text(app: tauri::AppHandle) -> Result<Option<String>, String> {
143+
let (tx, rx) = std::sync::mpsc::channel();
144+
app.run_on_main_thread(move || {
145+
let text = clipboard::read_text_from_clipboard();
146+
let _ = tx.send(text);
147+
})
148+
.map_err(|e| format!("Couldn't run on main thread: {e}"))?;
149+
150+
rx.recv()
151+
.map_err(|e| format!("Couldn't receive pasteboard result: {e}"))
152+
}
153+
154+
#[cfg(not(target_os = "macos"))]
155+
#[tauri::command]
156+
pub async fn read_clipboard_text(_app: tauri::AppHandle) -> Result<Option<String>, String> {
157+
Err("Clipboard operations are not yet supported on this platform".to_string())
158+
}
159+
136160
/// Clears the in-process cut state without touching the system clipboard.
137161
#[tauri::command]
138162
pub fn clear_clipboard_cut_state() {

apps/desktop/src-tauri/src/lib.rs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ mod volumes_linux;
108108
mod stubs;
109109

110110
use menu::{
111-
CLOSE_TAB_ID, CommandScope, MenuState, SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID, SORT_BY_CREATED_ID,
112-
SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID, SORT_DESCENDING_ID, TAB_CLOSE_ID,
113-
TAB_CLOSE_OTHERS_ID, TAB_PIN_ID, VIEW_MODE_BRIEF_ID, VIEW_MODE_FULL_ID, VIEWER_WORD_WRAP_ID, ViewMode,
114-
menu_id_to_command,
111+
CLOSE_TAB_ID, CommandScope, EDIT_COPY_ID, EDIT_CUT_ID, EDIT_PASTE_ID, MenuState, SHOW_HIDDEN_FILES_ID,
112+
SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID,
113+
SORT_BY_SIZE_ID, SORT_DESCENDING_ID, TAB_CLOSE_ID, TAB_CLOSE_OTHERS_ID, TAB_PIN_ID, VIEW_MODE_BRIEF_ID,
114+
VIEW_MODE_FULL_ID, VIEWER_WORD_WRAP_ID, ViewMode, menu_id_to_command,
115115
};
116116
use tauri::{Emitter, Manager};
117117

@@ -121,6 +121,39 @@ fn greet(name: &str) -> String {
121121
format!("Hello, {}! You've been greeted from Rust!", name)
122122
}
123123

124+
/// Sends a native clipboard action (copy:/cut:/paste:) through the responder chain.
125+
///
126+
/// Used when a non-main window is focused: the custom Edit menu items can't use the native
127+
/// responder chain like PredefinedMenuItems do, so we replicate it manually via
128+
/// `NSApplication.sendAction:to:from:` with nil target (routes to the first responder).
129+
#[cfg(target_os = "macos")]
130+
fn send_native_clipboard_action(menu_id: &str) {
131+
use objc2::sel;
132+
use objc2_app_kit::NSApplication;
133+
134+
let selector = match menu_id {
135+
EDIT_CUT_ID => sel!(cut:),
136+
EDIT_COPY_ID => sel!(copy:),
137+
EDIT_PASTE_ID => sel!(paste:),
138+
_ => return,
139+
};
140+
141+
// Safety: we're on the main thread (called from on_menu_event which runs on the main thread).
142+
let mtm = unsafe { objc2::MainThreadMarker::new_unchecked() };
143+
let ns_app = NSApplication::sharedApplication(mtm);
144+
145+
// sendAction:to:from: with nil `to` sends to the first responder, exactly like
146+
// PredefinedMenuItems do internally. This lets WKWebView handle text clipboard natively.
147+
unsafe {
148+
let _: bool = objc2::msg_send![
149+
&ns_app,
150+
sendAction: selector,
151+
to: std::ptr::null::<objc2::runtime::AnyObject>(),
152+
from: std::ptr::null::<objc2::runtime::AnyObject>(),
153+
];
154+
}
155+
}
156+
124157
#[cfg_attr(mobile, tauri::mobile_entry_point)]
125158
pub fn run() {
126159
let builder = tauri::Builder::default();
@@ -452,6 +485,34 @@ pub fn run() {
452485
return;
453486
}
454487

488+
// === Clipboard exception: file clipboard in main window, native text clipboard elsewhere ===
489+
// Custom MenuItems for Cut/Copy/Paste route through execute-command in the main window
490+
// so the frontend can decide between file and text clipboard. In non-main windows
491+
// (viewer, settings), we send the native action through the responder chain so
492+
// WKWebView handles text clipboard natively — just like PredefinedMenuItems would.
493+
if id == EDIT_CUT_ID || id == EDIT_COPY_ID || id == EDIT_PASTE_ID {
494+
let main_focused = app
495+
.get_webview_window("main")
496+
.is_some_and(|w| w.is_focused().unwrap_or(false));
497+
if main_focused {
498+
let command_id = match id {
499+
EDIT_CUT_ID => "edit.cut",
500+
EDIT_COPY_ID => "edit.copy",
501+
_ => "edit.paste",
502+
};
503+
let _ = app.emit_to(
504+
"main",
505+
"execute-command",
506+
serde_json::json!({ "commandId": command_id }),
507+
);
508+
} else {
509+
// Send native clipboard action to the first responder chain
510+
#[cfg(target_os = "macos")]
511+
send_native_clipboard_action(id);
512+
}
513+
return;
514+
}
515+
455516
// === Unified dispatch: look up command ID from the mapping ===
456517
if let Some((command_id, scope)) = menu_id_to_command(id) {
457518
if scope == CommandScope::FileScoped {
@@ -782,6 +843,7 @@ pub fn run() {
782843
commands::clipboard::copy_files_to_clipboard,
783844
commands::clipboard::cut_files_to_clipboard,
784845
commands::clipboard::read_clipboard_files,
846+
commands::clipboard::read_clipboard_text,
785847
commands::clipboard::clear_clipboard_cut_state,
786848
])
787849
.on_window_event(|window, event| {

apps/desktop/src-tauri/src/menu/CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,14 @@ also Window and Help.
108108
- **Tab as accelerator**: Switch pane uses Tab, which could conflict with menu bar accessibility
109109
navigation. If issues arise, omit the accelerator and rely on JS dispatch.
110110
- **Custom MenuItems for Cut/Copy/Paste**: The Edit menu uses custom MenuItems (not
111-
PredefinedMenuItems) for Cut, Copy, Paste, and Move here. This routes ⌘C/⌘V/⌘X through
112-
`execute-command` dispatch so the frontend can decide between text clipboard (when an input is
113-
focused) and file clipboard (when the file list has focus). Text clipboard is handled via
114-
`document.execCommand` / `navigator.clipboard` API in the frontend handler. Undo and Redo remain
115-
PredefinedMenuItems since they only apply to text fields.
111+
PredefinedMenuItems) for Cut, Copy, Paste, and Move here. In `on_menu_event`, these are handled
112+
specially: if the main window is focused, they route through `execute-command` so the frontend can
113+
decide between file clipboard and text clipboard (via `document.activeElement` check). If a
114+
non-main window is focused (viewer, settings), `send_native_clipboard_action()` in `lib.rs` sends
115+
the native `copy:`/`cut:`/`paste:` selector through the responder chain via
116+
`NSApplication.sendAction:to:from:` — replicating what PredefinedMenuItems do internally. This
117+
ensures text clipboard works natively in all windows. Undo and Redo remain PredefinedMenuItems
118+
since they only apply to text fields.
116119
- **⌘A dual routing**: "Select all" uses ⌘A as a native menu accelerator (so it's visible in the
117120
Edit menu). Since macOS intercepts it before the webview, the frontend's `handleCommandExecute`
118121
checks `document.activeElement` — if it's an input/textarea, it calls `.select()` for text

apps/desktop/src-tauri/src/menu/mod.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,13 @@ pub fn menu_id_to_command(menu_id: &str) -> Option<(&'static str, CommandScope)>
100100
PIN_TAB_MENU_ID => Some(("tab.togglePin", CommandScope::FileScoped)),
101101
CLOSE_OTHER_TABS_ID => Some(("tab.closeOthers", CommandScope::FileScoped)),
102102

103-
// Clipboard operations (App scope — text clipboard must work in all windows;
104-
// the frontend's activeElement check routes between text and file clipboard)
103+
// Clipboard operations — cut/copy/paste are handled specially in on_menu_event
104+
// (native responder chain for non-main windows, execute-command for main window).
105+
// They're still listed here for command_id_to_menu_id reverse lookups.
105106
EDIT_CUT_ID => Some(("edit.cut", CommandScope::App)),
106107
EDIT_COPY_ID => Some(("edit.copy", CommandScope::App)),
107108
EDIT_PASTE_ID => Some(("edit.paste", CommandScope::App)),
108-
EDIT_PASTE_MOVE_ID => Some(("edit.pasteAsMove", CommandScope::App)),
109+
EDIT_PASTE_MOVE_ID => Some(("edit.pasteAsMove", CommandScope::FileScoped)),
109110

110111
// File operations (file-scoped)
111112
OPEN_ID => Some(("nav.open", CommandScope::FileScoped)),

apps/desktop/src/lib/tauri-commands/clipboard-files.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export async function readClipboardFiles(): Promise<ClipboardReadResult> {
4141
return invoke<ClipboardReadResult>('read_clipboard_files')
4242
}
4343

44+
export async function readClipboardText(): Promise<string | null> {
45+
return invoke<string | null>('read_clipboard_text')
46+
}
47+
4448
export async function clearClipboardCutState(): Promise<void> {
4549
await invoke('clear_clipboard_cut_state')
4650
}

apps/desktop/src/lib/tauri-commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export {
277277
copyFilesToClipboard,
278278
cutFilesToClipboard,
279279
readClipboardFiles,
280+
readClipboardText,
280281
clearClipboardCutState,
281282
} from './clipboard-files'
282283
export type { ClipboardReadResult } from './clipboard-files'

apps/desktop/src/routes/(main)/+page.svelte

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
setMenuContext,
3030
getWindowTitle,
3131
registerKnownDialogs,
32+
readClipboardText,
3233
} from '$lib/tauri-commands'
3334
import { SOFT_DIALOG_REGISTRY } from '$lib/ui/dialog-registry'
3435
import { addToast } from '$lib/ui/toast'
@@ -991,17 +992,12 @@
991992
active instanceof HTMLTextAreaElement ||
992993
active?.closest('[contenteditable]')
993994
) {
994-
// execCommand('paste') is the native paste that works without WebKit's
995-
// clipboard permission popup. Falls back to navigator.clipboard API.
996-
// eslint-disable-next-line @typescript-eslint/no-deprecated -- No modern alternative for triggering native paste in text inputs
997-
if (!document.execCommand('paste')) {
998-
try {
999-
const text = await navigator.clipboard.readText()
1000-
// eslint-disable-next-line @typescript-eslint/no-deprecated -- insertText is the only way to insert at cursor position in inputs
1001-
document.execCommand('insertText', false, text)
1002-
} catch {
1003-
// Both methods failed — clipboard may be empty or inaccessible
1004-
}
995+
// Read clipboard text via Rust (bypasses WebKit's navigator.clipboard
996+
// permission popup that shows a "Paste" button the user must click).
997+
const text = await readClipboardText()
998+
if (text) {
999+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- insertText is the only way to insert at cursor position in inputs
1000+
document.execCommand('insertText', false, text)
10051001
}
10061002
return
10071003
}

0 commit comments

Comments
 (0)