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
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:
@@ -22,7 +22,7 @@ quinn = "0.11"
|
||||
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.
|
||||
# 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]
|
||||
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
|
||||
|
||||
@@ -235,6 +235,12 @@ pub const WIRE_HTML: &str = "text/html";
|
||||
pub const WIRE_RTF: &str = "text/rtf";
|
||||
/// Wire MIME for a PNG image.
|
||||
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
|
||||
/// 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/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
|
||||
"image/png" => Some(WIRE_PNG),
|
||||
"image/jpeg" => Some(WIRE_JPEG),
|
||||
"image/gif" => Some(WIRE_GIF),
|
||||
_ => match base {
|
||||
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
|
||||
_ => None,
|
||||
@@ -270,6 +278,8 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
|
||||
WIRE_HTML => &["text/html"],
|
||||
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
|
||||
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");
|
||||
}
|
||||
WIRE_PNG => push("image/png"),
|
||||
WIRE_JPEG => push("image/jpeg"),
|
||||
WIRE_GIF => push("image/gif"),
|
||||
other => push(other),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
|
||||
};
|
||||
|
||||
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.
|
||||
const WM_APP_CMD: u32 = WM_APP + 1;
|
||||
@@ -98,6 +100,13 @@ struct WinClip {
|
||||
fmt_html: u32,
|
||||
fmt_rtf: 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.
|
||||
own_hwnd: HWND,
|
||||
}
|
||||
@@ -137,6 +146,15 @@ impl WinClip {
|
||||
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -192,6 +210,7 @@ impl WinClip {
|
||||
}
|
||||
}
|
||||
*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).
|
||||
@@ -265,8 +284,23 @@ impl WinClip {
|
||||
/// `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.
|
||||
fn on_render_format(&self, fmt: u32) {
|
||||
let Some(wire) = self.wire_for_format(fmt) else {
|
||||
return;
|
||||
// The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
|
||||
// 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 ev = ClipEvent::Paste {
|
||||
@@ -281,9 +315,9 @@ impl WinClip {
|
||||
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
||||
};
|
||||
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
|
||||
// decode leaves the format unrendered (empty paste), matching the timeout path.
|
||||
match winfmt::png_to_dib(&bytes) {
|
||||
// The app asked for a bitmap: convert whatever image kind the client served. Bytes
|
||||
// that don't decode leave the format unrendered (empty paste), like the timeout path.
|
||||
match winfmt::image_to_dib(&bytes) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
}
|
||||
@@ -310,6 +344,8 @@ impl WinClip {
|
||||
WIRE_HTML => Some(self.fmt_html),
|
||||
WIRE_RTF => Some(self.fmt_rtf),
|
||||
WIRE_PNG => Some(self.fmt_png),
|
||||
WIRE_JPEG => Some(self.fmt_jfif),
|
||||
WIRE_GIF => Some(self.fmt_gif),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -322,8 +358,12 @@ impl WinClip {
|
||||
Some(WIRE_HTML)
|
||||
} else if fmt == self.fmt_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)
|
||||
} else if fmt == self.fmt_jfif {
|
||||
Some(WIRE_JPEG)
|
||||
} else if fmt == self.fmt_gif {
|
||||
Some(WIRE_GIF)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -340,9 +380,12 @@ impl WinClip {
|
||||
}
|
||||
}
|
||||
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
|
||||
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes
|
||||
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand.
|
||||
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) {
|
||||
// ask for the bitmap family, not the registered image formats; Windows synthesizes
|
||||
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -376,12 +419,18 @@ impl WindowsClipboard {
|
||||
let fmt_html = register_format(w!("HTML Format"))?;
|
||||
let fmt_rtf = register_format(w!("Rich Text Format"))?;
|
||||
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 cw = Arc::clone(¤t_wire);
|
||||
let join = std::thread::Builder::new()
|
||||
.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")?;
|
||||
|
||||
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`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pump_thread(
|
||||
clip_tx: ClipTx,
|
||||
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
|
||||
@@ -580,6 +630,8 @@ fn pump_thread(
|
||||
fmt_html: u32,
|
||||
fmt_rtf: u32,
|
||||
fmt_png: u32,
|
||||
fmt_jfif: u32,
|
||||
fmt_gif: u32,
|
||||
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
|
||||
) {
|
||||
let hwnd = match create_window() {
|
||||
@@ -598,9 +650,12 @@ fn pump_thread(
|
||||
current_wire,
|
||||
cmd_rx: RefCell::new(cmd_rx),
|
||||
offered: RefCell::new(Vec::new()),
|
||||
offered_wires: RefCell::new(Vec::new()),
|
||||
fmt_html,
|
||||
fmt_rtf,
|
||||
fmt_png,
|
||||
fmt_jfif,
|
||||
fmt_gif,
|
||||
own_hwnd: hwnd,
|
||||
});
|
||||
let ptr = Box::into_raw(state);
|
||||
|
||||
@@ -265,7 +265,7 @@ mod tests {
|
||||
image::DynamicImage::ImageRgba8(img.clone())
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.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.
|
||||
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);
|
||||
@@ -290,10 +290,11 @@ mod tests {
|
||||
// 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.
|
||||
|
||||
/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up).
|
||||
/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste).
|
||||
pub fn png_to_dib(png: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?;
|
||||
/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL
|
||||
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
|
||||
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
|
||||
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||
|
||||
Reference in New Issue
Block a user