Skip to content

Commit e1b10fb

Browse files
JSKittyclaude
andcommitted
fix(windows): clipboard paste — probe first, real backoff, in-band fallback
The Windows clipboard open is system-global exclusive and WebView2's process snapshots it around the paste event; clipboard-win's built-in retries are Sleep(0) scheduler yields that burn out inside that window, and the failure was swallowed into an empty list after the frontend had already preventDefault'ed — a completely silent dead paste. - Read probes CF_HDROP without opening the clipboard (text and image pastes no longer touch the contended open at all), retries the open with a real 10ms backoff, and surfaces the final failure instead of faking an empty file list. - Write replaces the yield-spin with the same backoff. - The paste handler captures the first file item of ANY kind (not just images) synchronously — when the native path read fails, the file's in-band bytes and real filename still open the preview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fe7729b commit e1b10fb

2 files changed

Lines changed: 70 additions & 25 deletions

File tree

src-tauri/src/commands/clipboard.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,30 @@ fn read_clipboard_files_impl() -> Result<Vec<String>, String> {
7373

7474
#[cfg(target_os = "windows")]
7575
fn read_clipboard_files_impl() -> Result<Vec<String>, String> {
76-
// CF_HDROP → Vec<String> of paths. Absent format (text/image/empty clipboard)
77-
// or a transient open failure both yield an empty list so paste falls back,
78-
// mirroring the other platforms (a missing file list is not an error here).
79-
Ok(clipboard_win::get_clipboard(clipboard_win::formats::FileList).unwrap_or_default())
76+
// Probe CF_HDROP WITHOUT opening the clipboard (IsClipboardFormatAvailable):
77+
// this runs on every paste, and a text/image clipboard must not contend for
78+
// the open at all. Absent format = no file paste, not an error.
79+
if !clipboard_win::is_format_avail(clipboard_win::formats::CF_HDROP) {
80+
return Ok(Vec::new());
81+
}
82+
// The open races the WebView2 PROCESS, which snapshots the clipboard around
83+
// the paste event — and clipboard-win's built-in retries are Sleep(0)
84+
// scheduler yields that burn out inside that window. Retry with a real
85+
// backoff, and surface the final failure instead of swallowing it into an
86+
// empty list: the frontend catches and falls back to the in-band file blob.
87+
let mut last_err = String::new();
88+
for attempt in 0..20u32 {
89+
match clipboard_win::get_clipboard::<Vec<String>, _>(clipboard_win::formats::FileList) {
90+
Ok(paths) => return Ok(paths),
91+
Err(e) => {
92+
last_err = e.to_string();
93+
if attempt < 19 {
94+
std::thread::sleep(std::time::Duration::from_millis(10));
95+
}
96+
}
97+
}
98+
}
99+
Err(format!("clipboard file read failed: {last_err}"))
80100
}
81101

82102
#[cfg(target_os = "linux")]
@@ -165,7 +185,21 @@ fn write_clipboard_files_impl(paths: Vec<String>) -> Result<(), String> {
165185
// `set_clipboard` helper can't reach it — call the trait method on a slice
166186
// under our own clipboard guard instead.
167187
use clipboard_win::{formats::FileList, Clipboard, Setter};
168-
let _clip = Clipboard::new_attempts(10).map_err(|e| format!("Failed to open clipboard: {}", e))?;
188+
// new_attempts retries are Sleep(0) yields — worthless against another
189+
// process holding the clipboard (WebView2, clipboard-history tools). Real
190+
// backoff: ~200ms worst case before giving up.
191+
let mut clip = None;
192+
for attempt in 0..20u32 {
193+
match Clipboard::new_attempts(0) {
194+
Ok(c) => {
195+
clip = Some(c);
196+
break;
197+
}
198+
Err(e) if attempt == 19 => return Err(format!("Failed to open clipboard: {}", e)),
199+
Err(_) => std::thread::sleep(std::time::Duration::from_millis(10)),
200+
}
201+
}
202+
let _clip = clip;
169203
FileList
170204
.write_clipboard(&paths[..])
171205
.map_err(|e| format!("Clipboard write failed: {}", e))

src/main.js

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11888,12 +11888,16 @@ window.addEventListener("DOMContentLoaded", async () => {
1188811888
document.onpaste = async (evt) => {
1188911889
if (strOpenChat) {
1189011890
const dt = evt.clipboardData;
11891-
// clipboardData is only valid during synchronous dispatch — capture the
11892-
// image item + its blob NOW, since the await below would invalidate it.
11891+
// clipboardData is only valid during synchronous dispatch — capture any
11892+
// file item + its blob NOW, since the await below would invalidate it.
11893+
// ANY file kind, not just images: when the native path read fails (a
11894+
// clipboard held by another process, a manager that dropped CF_HDROP),
11895+
// the in-band blob is the universal fallback and carries a real name.
1189311896
const arrItems = Array.from(dt?.items || []);
11894-
const imageItem = arrItems.find(item => item.type.startsWith('image/'));
11895-
const imageBlob = imageItem ? imageItem.getAsFile() : null;
11896-
const imageType = imageItem ? imageItem.type : '';
11897+
const fileItem = arrItems.find(item => item.kind === 'file')
11898+
|| arrItems.find(item => item.type.startsWith('image/'));
11899+
const fileBlob = fileItem ? fileItem.getAsFile() : null;
11900+
const fileMime = fileItem ? fileItem.type : '';
1189711901

1189811902
// A file copy (Finder/Explorer) also carries a text representation of the
1189911903
// path, so the default paste inserts the filename into the input. We must
@@ -11903,7 +11907,7 @@ window.addEventListener("DOMContentLoaded", async () => {
1190311907
const hasFile = (dt?.files && dt.files.length > 0)
1190411908
|| arrItems.some(it => it.kind === 'file')
1190511909
|| dtTypes.includes('Files');
11906-
if (hasFile || imageBlob) evt.preventDefault();
11910+
if (hasFile || fileBlob) evt.preventDefault();
1190711911

1190811912
// Snapshot the composer so we can scrub any filename text that still
1190911913
// slipped in (e.g. a folder copy whose sync signal we couldn't read).
@@ -11939,33 +11943,40 @@ window.addEventListener("DOMContentLoaded", async () => {
1193911943
console.warn('[paste] native file read failed, falling back to image bytes:', e);
1194011944
}
1194111945

11942-
// Fall back to raw image bytes (screenshot data with no file reference).
11943-
if (imageBlob) {
11946+
// Fall back to the in-band blob: screenshot bytes, or a copied file
11947+
// whose native path read failed (its content still rides the event).
11948+
if (fileBlob) {
1194411949
restoreInput();
1194511950

1194611951
// Read the blob as bytes
11947-
const arrayBuffer = await imageBlob.arrayBuffer();
11952+
const arrayBuffer = await fileBlob.arrayBuffer();
1194811953
const bytes = new Uint8Array(arrayBuffer);
1194911954

11950-
// Determine file extension from MIME type
11951-
const mimeType = imageType;
11955+
// Prefer the blob's real filename; synthesize one from the MIME
11956+
// type only for anonymous data (screenshots).
1195211957
let ext = 'png'; // Default
11953-
if (mimeType.includes('jpeg') || mimeType.includes('jpg')) {
11958+
if (fileMime.includes('jpeg') || fileMime.includes('jpg')) {
1195411959
ext = 'jpg';
11955-
} else if (mimeType.includes('gif')) {
11960+
} else if (fileMime.includes('gif')) {
1195611961
ext = 'gif';
11957-
} else if (mimeType.includes('webp')) {
11962+
} else if (fileMime.includes('webp')) {
1195811963
ext = 'webp';
11959-
} else if (mimeType.includes('png')) {
11964+
} else if (fileMime.includes('png')) {
1196011965
ext = 'png';
11961-
} else if (mimeType.includes('tiff')) {
11966+
} else if (fileMime.includes('tiff')) {
1196211967
ext = 'tiff';
11963-
} else if (mimeType.includes('bmp')) {
11968+
} else if (fileMime.includes('bmp')) {
1196411969
ext = 'bmp';
11970+
} else if (!fileMime.startsWith('image/')) {
11971+
ext = 'bin';
11972+
}
11973+
let fileName = `pasted_image.${ext}`;
11974+
if (fileBlob.name && fileBlob.name.includes('.')) {
11975+
fileName = fileBlob.name;
11976+
ext = fileBlob.name.split('.').pop().toLowerCase();
11977+
} else if (fileBlob.name) {
11978+
fileName = fileBlob.name;
1196511979
}
11966-
11967-
// Generate a filename
11968-
const fileName = `pasted_image.${ext}`;
1196911980

1197011981
// Get reply reference before opening preview
1197111982
const strReplyRef = strCurrentReplyReference;

0 commit comments

Comments
 (0)