feat(clipboard): pass original JPEG/GIF through verbatim beside the PNG floor
android / android (push) Has been cancelled
audit / bun-audit (push) Successful in 14s
audit / cargo-audit (push) Successful in 2m29s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
apple / swift (push) Successful in 1m23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 5m58s
ci / rust (push) Failing after 6m54s
release / apple (push) Successful in 5m52s
arch / build-publish (push) Failing after 11m7s
flatpak / build-publish (push) Failing after 8m7s
deb / build-publish (push) Successful in 13m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m31s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m5s
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled

Transcoding every image to PNG was the Phase-1 floor, but it bloats lossy
originals (a copied JPEG re-encoded lossless is 5-10x the bytes for zero
quality gain — feeding the render-timeout on constrained links) and flattens
GIFs to one frame. The wire already carries kind LISTS, so offer the original
format beside the portable fallback and let the destination pick:

- New wire kinds image/jpeg + image/gif (§3.5 extension; strings only, no
  protocol change). image/png stays the universal floor every peer accepts.
- macOS: public.jpeg / com.compuserve.gif rows pass through verbatim both
  directions; the PNG floor is announced whenever any image is present.
- Windows: registered JFIF/GIF formats read + promised verbatim; every image
  offer still promises CF_DIB, whose delayed render now fetches the richest
  offered kind (png > jpeg > gif) through the generalized image_to_dib.
- Linux maps gain the matching rows (native MIME pass-through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:32:03 +02:00
parent bbe4380b41
commit f4b52d0bf5
6 changed files with 198 additions and 21 deletions
Generated
+99
View File
@@ -560,6 +560,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.0"
@@ -756,6 +762,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -1141,6 +1153,15 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "fec-rs" name = "fec-rs"
version = "0.1.0" version = "0.1.0"
@@ -1495,6 +1516,16 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.6" version = "0.22.6"
@@ -1988,6 +2019,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -2386,6 +2434,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "nasm-rs" name = "nasm-rs"
version = "0.3.2" version = "0.3.2"
@@ -2805,6 +2863,7 @@ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
"futures-util", "futures-util",
"image",
"libc", "libc",
"punktfunk-core", "punktfunk-core",
"quinn", "quinn",
@@ -3075,6 +3134,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3382,6 +3454,12 @@ dependencies = [
"winresource", "winresource",
] ]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.13.0" version = "0.13.0"
@@ -5202,6 +5280,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "wide" name = "wide"
version = "0.7.33" version = "0.7.33"
@@ -6014,6 +6098,21 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.12.0" version = "5.12.0"
@@ -29,6 +29,11 @@ public final class ClipboardSync: NSObject {
("text/rtf", .rtf), ("text/rtf", .rtf),
("text/html", .html), ("text/html", .html),
("image/png", .png), ("image/png", .png),
// Original image formats pass through VERBATIM beside the PNG floor a copied JPEG
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
// kind it can place.
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
] ]
/// Pasteboard marker types that must never cross the wire (password managers mark secrets /// Pasteboard marker types that must never cross the wire (password managers mark secrets
/// with these see nspasteboard.org). /// with these see nspasteboard.org).
@@ -205,11 +210,14 @@ public final class ClipboardSync: NSObject {
var kinds = Self.wireToPasteboard var kinds = Self.wireToPasteboard
.filter { types.contains($0.type) } .filter { types.contains($0.type) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) } .map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots // PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// TIFF) and only sometimes PNG announce the portable `image/png` whenever ANY // native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5). // offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
// fallback costs nothing unless a peer actually pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }), if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic")) types.contains(.tiff)
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{ {
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png")) kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
} }
@@ -385,6 +393,8 @@ private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider
case .rtf: return "text/rtf" case .rtf: return "text/rtf"
case .html: return "text/html" case .html: return "text/html"
case .png: return "image/png" case .png: return "image/png"
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
default: return nil default: return nil
} }
} }
+1 -1
View File
@@ -22,7 +22,7 @@ quinn = "0.11"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format. # CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host. # Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
image = { version = "0.25", default-features = false, features = ["png", "bmp"] } image = { version = "0.25", default-features = false, features = ["png", "bmp", "jpeg", "gif"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg # Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
+12
View File
@@ -235,6 +235,12 @@ pub const WIRE_HTML: &str = "text/html";
pub const WIRE_RTF: &str = "text/rtf"; pub const WIRE_RTF: &str = "text/rtf";
/// Wire MIME for a PNG image. /// Wire MIME for a PNG image.
pub const WIRE_PNG: &str = "image/png"; pub const WIRE_PNG: &str = "image/png";
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
pub const WIRE_JPEG: &str = "image/jpeg";
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
pub const WIRE_GIF: &str = "image/gif";
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets /// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases /// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
@@ -248,6 +254,8 @@ pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
"text/html" => Some(WIRE_HTML), "text/html" => Some(WIRE_HTML),
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF), "text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
"image/png" => Some(WIRE_PNG), "image/png" => Some(WIRE_PNG),
"image/jpeg" => Some(WIRE_JPEG),
"image/gif" => Some(WIRE_GIF),
_ => match base { _ => match base {
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT), "text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
_ => None, _ => None,
@@ -270,6 +278,8 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
WIRE_HTML => &["text/html"], WIRE_HTML => &["text/html"],
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"], WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
WIRE_PNG => &["image/png"], WIRE_PNG => &["image/png"],
WIRE_JPEG => &["image/jpeg"],
WIRE_GIF => &["image/gif"],
_ => &[], _ => &[],
} }
} }
@@ -330,6 +340,8 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
push("text/rtf"); push("text/rtf");
} }
WIRE_PNG => push("image/png"), WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other), other => push(other),
} }
} }
+66 -11
View File
@@ -48,7 +48,9 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
}; };
use super::winfmt; use super::winfmt;
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT}; use super::{
ClipEvent, PasteResponder, WIRE_GIF, WIRE_HTML, WIRE_JPEG, WIRE_PNG, WIRE_RTF, WIRE_TEXT,
};
/// Custom app message that wakes the pump to drain the [`Cmd`] channel. /// Custom app message that wakes the pump to drain the [`Cmd`] channel.
const WM_APP_CMD: u32 = WM_APP + 1; const WM_APP_CMD: u32 = WM_APP + 1;
@@ -98,6 +100,13 @@ struct WinClip {
fmt_html: u32, fmt_html: u32,
fmt_rtf: u32, fmt_rtf: u32,
fmt_png: u32, fmt_png: u32,
/// Registered `"JFIF"` — the conventional raw-JPEG clipboard format (Office/browsers).
fmt_jfif: u32,
/// Registered `"GIF"` — raw GIF bytes (animation preserved by verbatim pass-through).
fmt_gif: u32,
/// The wire MIMEs of the offer currently promised via delayed rendering — `WM_RENDERFORMAT`
/// for the synthesized `CF_DIB` picks the richest image kind among them to fetch + convert.
offered_wires: RefCell<Vec<String>>,
/// Our own message window — used for the owner-check and clipboard opens. /// Our own message window — used for the owner-check and clipboard opens.
own_hwnd: HWND, own_hwnd: HWND,
} }
@@ -137,6 +146,15 @@ impl WinClip {
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) { if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
out.push(WIRE_PNG.to_string()); out.push(WIRE_PNG.to_string());
} }
// Original lossy/animated formats offered VERBATIM beside the PNG floor — the client
// picks the richest kind it can place, so a copied JPEG never balloons into PNG and a
// GIF keeps its animation.
if avail(self.fmt_jfif) {
out.push(WIRE_JPEG.to_string());
}
if avail(self.fmt_gif) {
out.push(WIRE_GIF.to_string());
}
out out
} }
@@ -192,6 +210,7 @@ impl WinClip {
} }
} }
*self.offered.borrow_mut() = fmts; *self.offered.borrow_mut() = fmts;
*self.offered_wires.borrow_mut() = wire.to_vec();
} }
/// Drop the selection we own (empty the clipboard iff we're still its owner). /// Drop the selection we own (empty the clipboard iff we're still its owner).
@@ -265,8 +284,23 @@ impl WinClip {
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client /// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster. /// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
fn on_render_format(&self, fmt: u32) { fn on_render_format(&self, fmt: u32) {
let Some(wire) = self.wire_for_format(fmt) else { // The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
return; // kind the client offered (PNG first — lossless with alpha — then JPEG, then GIF's first
// frame) and convert. Every other format maps 1:1.
let wire: &str = if fmt == CF_DIB.0 as u32 {
let offered = self.offered_wires.borrow();
match [WIRE_PNG, WIRE_JPEG, WIRE_GIF]
.into_iter()
.find(|w| offered.iter().any(|o| o == w))
{
Some(w) => w,
None => return,
}
} else {
match self.wire_for_format(fmt) {
Some(w) => w,
None => return,
}
}; };
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>(); let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let ev = ClipEvent::Paste { let ev = ClipEvent::Paste {
@@ -281,9 +315,9 @@ impl WinClip {
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste) Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
}; };
let win_bytes = if fmt == CF_DIB.0 as u32 { let win_bytes = if fmt == CF_DIB.0 as u32 {
// The app asked for a bitmap: the client served PNG — convert. A PNG that doesn't // The app asked for a bitmap: convert whatever image kind the client served. Bytes
// decode leaves the format unrendered (empty paste), matching the timeout path. // that don't decode leave the format unrendered (empty paste), like the timeout path.
match winfmt::png_to_dib(&bytes) { match winfmt::image_to_dib(&bytes) {
Some(d) => d, Some(d) => d,
None => return, None => return,
} }
@@ -310,6 +344,8 @@ impl WinClip {
WIRE_HTML => Some(self.fmt_html), WIRE_HTML => Some(self.fmt_html),
WIRE_RTF => Some(self.fmt_rtf), WIRE_RTF => Some(self.fmt_rtf),
WIRE_PNG => Some(self.fmt_png), WIRE_PNG => Some(self.fmt_png),
WIRE_JPEG => Some(self.fmt_jfif),
WIRE_GIF => Some(self.fmt_gif),
_ => None, _ => None,
} }
} }
@@ -322,8 +358,12 @@ impl WinClip {
Some(WIRE_HTML) Some(WIRE_HTML)
} else if fmt == self.fmt_rtf { } else if fmt == self.fmt_rtf {
Some(WIRE_RTF) Some(WIRE_RTF)
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 { } else if fmt == self.fmt_png {
Some(WIRE_PNG) Some(WIRE_PNG)
} else if fmt == self.fmt_jfif {
Some(WIRE_JPEG)
} else if fmt == self.fmt_gif {
Some(WIRE_GIF)
} else { } else {
None None
} }
@@ -340,9 +380,12 @@ impl WinClip {
} }
} }
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients) // Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes // ask for the bitmap family, not the registered image formats; Windows synthesizes
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand. // CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) { // offered image kind and converts on demand.
if matches!(w.as_str(), WIRE_PNG | WIRE_JPEG | WIRE_GIF)
&& !out.contains(&(CF_DIB.0 as u32))
{
out.push(CF_DIB.0 as u32); out.push(CF_DIB.0 as u32);
} }
} }
@@ -376,12 +419,18 @@ impl WindowsClipboard {
let fmt_html = register_format(w!("HTML Format"))?; let fmt_html = register_format(w!("HTML Format"))?;
let fmt_rtf = register_format(w!("Rich Text Format"))?; let fmt_rtf = register_format(w!("Rich Text Format"))?;
let fmt_png = register_format(w!("PNG"))?; let fmt_png = register_format(w!("PNG"))?;
let fmt_jfif = register_format(w!("JFIF"))?;
let fmt_gif = register_format(w!("GIF"))?;
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>(); let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
let cw = Arc::clone(&current_wire); let cw = Arc::clone(&current_wire);
let join = std::thread::Builder::new() let join = std::thread::Builder::new()
.name("punktfunk-clipboard-win".into()) .name("punktfunk-clipboard-win".into())
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx)) .spawn(move || {
pump_thread(
clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, fmt_jfif, fmt_gif, ready_tx,
)
})
.context("spawn windows clipboard thread")?; .context("spawn windows clipboard thread")?;
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await { let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
@@ -573,6 +622,7 @@ fn create_window() -> anyhow::Result<HWND> {
} }
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`. /// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
#[allow(clippy::too_many_arguments)]
fn pump_thread( fn pump_thread(
clip_tx: ClipTx, clip_tx: ClipTx,
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>, cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
@@ -580,6 +630,8 @@ fn pump_thread(
fmt_html: u32, fmt_html: u32,
fmt_rtf: u32, fmt_rtf: u32,
fmt_png: u32, fmt_png: u32,
fmt_jfif: u32,
fmt_gif: u32,
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>, ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
) { ) {
let hwnd = match create_window() { let hwnd = match create_window() {
@@ -598,9 +650,12 @@ fn pump_thread(
current_wire, current_wire,
cmd_rx: RefCell::new(cmd_rx), cmd_rx: RefCell::new(cmd_rx),
offered: RefCell::new(Vec::new()), offered: RefCell::new(Vec::new()),
offered_wires: RefCell::new(Vec::new()),
fmt_html, fmt_html,
fmt_rtf, fmt_rtf,
fmt_png, fmt_png,
fmt_jfif,
fmt_gif,
own_hwnd: hwnd, own_hwnd: hwnd,
}); });
let ptr = Box::into_raw(state); let ptr = Box::into_raw(state);
+6 -5
View File
@@ -265,7 +265,7 @@ mod tests {
image::DynamicImage::ImageRgba8(img.clone()) image::DynamicImage::ImageRgba8(img.clone())
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap(); .unwrap();
let dib = png_to_dib(&png).expect("png -> dib"); let dib = image_to_dib(&png).expect("png -> dib");
// BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp. // BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp.
assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40); assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40);
assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3); assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3);
@@ -290,10 +290,11 @@ mod tests {
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER: // the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows. // BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). /// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL
/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste). /// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
pub fn png_to_dib(png: &[u8]) -> Option<Vec<u8>> { /// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?; pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize); let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 { if w == 0 || h == 0 || w > 32767 || h > 32767 {