From bbfe5834230f62c909b8855c855c54eb0ab8f5a8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 23 Dec 2025 09:15:21 +0300 Subject: [PATCH 1/2] multicall: prevent newline corruption of binary data in wl-copy Previously we unconditionally appended a newline to all clipboard contents, which ended up corrupting binary files like PNG images when using shell redirection (e.g., `wl-paste > file.png`). Now we intelligently (in quotes) detect content type via MIME type and only append newlines to text-based content such as `text/*`, `application/json` and so on. Binary data on another hand is written exactly as it is. Falls back to UTF-8 validation when MIME type is unavailable. On paper this is also fully backwards compatible; text content still gets newline by default *unless* the `--no-newline` flag is used. Fixes #52 Signed-off-by: NotAShelf Change-Id: I8b1e6f7013d081150be761820cafd1926a6a6964 --- src/multicall/wl_paste.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 6aae54f..594dbfb 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -366,7 +366,7 @@ fn handle_regular_paste( let mime_type = get_paste_mime_type(args.mime_type.as_deref()); match get_contents(clipboard, seat, mime_type) { - Ok((mut reader, _types)) => { + Ok((mut reader, types)) => { let mut out = io::stdout(); let mut buf = Vec::new(); let mut temp_buffer = [0; 8192]; @@ -396,7 +396,20 @@ fn handle_regular_paste( if let Err(e) = out.write_all(&buf) { bail!("failed to write to stdout: {e}"); } - if !args.no_newline && !buf.ends_with(b"\n") { + + // Only add newline for text content, not binary data + // Check if the MIME type indicates text content + let is_text_content = if !types.is_empty() { + types.starts_with("text/") + || types == "application/json" + || types == "application/xml" + || types == "application/x-sh" + } else { + // If no MIME type, check if content is valid UTF-8 + std::str::from_utf8(&buf).is_ok() + }; + + if !args.no_newline && is_text_content && !buf.ends_with(b"\n") { if let Err(e) = out.write_all(b"\n") { bail!("failed to write newline to stdout: {e}"); } From f2274aa524c16001887f0d7af4e68bef683b216c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 23 Dec 2025 10:09:47 +0300 Subject: [PATCH 2/2] multicall: auto-select MIME type more intelligently when not specified Signed-off-by: NotAShelf Change-Id: Idfd5ab25079161d694bda429e70500a16a6a6964 --- src/multicall/wl_paste.rs | 74 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 594dbfb..af686c4 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -358,12 +358,74 @@ fn execute_watch_command( Ok(()) } +/// Select the best MIME type from available types when none is specified. +/// Prefers specific content types (image/*, application/*) over generic +/// text representations (TEXT, STRING, UTF8_STRING). +fn select_best_mime_type( + types: &std::collections::HashSet, +) -> Option { + if types.is_empty() { + return None; + } + + // If only one type available, use it + if types.len() == 1 { + return types.iter().next().cloned(); + } + + // Prefer specific MIME types with slashes (e.g., image/png, application/pdf) + // over generic X11 selections (TEXT, STRING, UTF8_STRING) + let specific_types: Vec<_> = + types.iter().filter(|t| t.contains('/')).collect(); + + if !specific_types.is_empty() { + // Among specific types, prefer non-text types first + for mime in &specific_types { + if !mime.starts_with("text/") { + return Some((*mime).clone()); + } + } + // If all are text types, prefer text/plain with charset + for mime in &specific_types { + if mime.starts_with("text/plain;charset=") { + return Some((*mime).clone()); + } + } + // Otherwise return first specific type + return Some(specific_types[0].clone()); + } + + // Fall back to generic text selections in order of preference + for fallback in &["UTF8_STRING", "STRING", "TEXT"] { + if types.contains(*fallback) { + return Some((*fallback).to_string()); + } + } + + // Last resort: return any available type + types.iter().next().cloned() +} + fn handle_regular_paste( args: &WlPasteArgs, clipboard: PasteClipboardType, seat: PasteSeat, ) -> Result<()> { - let mime_type = get_paste_mime_type(args.mime_type.as_deref()); + // If no MIME type specified, select the best available MIME type + let available_types = if args.mime_type.is_none() { + get_mime_types(clipboard, seat).ok() + } else { + None + }; + + let selected_type = available_types.as_ref().and_then(select_best_mime_type); + + let mime_type = if let Some(ref best) = selected_type { + log::debug!("Auto-selecting MIME type: {}", best); + PasteMimeType::Specific(best) + } else { + get_paste_mime_type(args.mime_type.as_deref()) + }; match get_contents(clipboard, seat, mime_type) { Ok((mut reader, types)) => { @@ -409,10 +471,12 @@ fn handle_regular_paste( std::str::from_utf8(&buf).is_ok() }; - if !args.no_newline && is_text_content && !buf.ends_with(b"\n") { - if let Err(e) = out.write_all(b"\n") { - bail!("failed to write newline to stdout: {e}"); - } + if !args.no_newline + && is_text_content + && !buf.ends_with(b"\n") + && let Err(e) = out.write_all(b"\n") + { + bail!("failed to write newline to stdout: {e}"); } }, Err(PasteError::NoSeats) => {