fix(apple/clipboard): announce + serve image/png from ANY pasteboard image type
apple / swift (push) Successful in 1m20s
ci / web (push) Successful in 1m31s
ci / docs-site (push) Successful in 1m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
ci / rust (push) Failing after 4m14s
arch / build-publish (push) Failing after 4m39s
docker / deploy-docs (push) Successful in 26s
deb / build-publish (push) Failing after 4m52s
ci / bench (push) Successful in 6m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m4s
apple / screenshots (push) Successful in 6m16s
android / android (push) Successful in 12m40s

macOS image copies rarely carry public.png — screenshots/Preview put TIFF on
the pasteboard, browsers add WebP/AVIF/GIF (observed live: TIFF+RTFD+WebP+AVIF+
8BPS+GIF, no PNG) — so the literal .png announce never fired and images
silently didn't sync. Announce image/png whenever a convertible image is
present (TIFF/HEIC alongside the native PNG check) and convert at serve time
via NSImage -> NSBitmapImageRep PNG (lazy, per design §3.5 — bytes still cross
only on a host paste).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:43:00 +02:00
parent 78d018ae2f
commit c28b10a5b9
3 changed files with 145 additions and 5 deletions
+3
View File
@@ -20,6 +20,9 @@ anyhow = "1"
tracing = "0.1"
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"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
+33 -5
View File
@@ -39,7 +39,7 @@ use ::windows::Win32::System::LibraryLoader::GetModuleHandleW;
use ::windows::Win32::System::Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT,
};
use ::windows::Win32::System::Ole::CF_UNICODETEXT;
use ::windows::Win32::System::Ole::{CF_DIB, CF_UNICODETEXT};
use ::windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
@@ -131,7 +131,10 @@ impl WinClip {
if avail(self.fmt_rtf) {
out.push(WIRE_RTF.to_string());
}
if avail(self.fmt_png) {
// Most apps put only the bitmap family on the clipboard (CF_DIB, from which Windows
// synthesizes CF_BITMAP/CF_DIBV5); browsers add the registered "PNG". Either serves a
// client image fetch — `read` converts DIB -> PNG when "PNG" itself is absent.
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
out.push(WIRE_PNG.to_string());
}
out
@@ -219,9 +222,16 @@ impl WinClip {
/// Read one wire format of the current host selection (a client fetch).
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
let fmt = self
let mut fmt = self
.format_for_wire(wire)
.context("unsupported wire MIME")?;
// Image fetch with no native "PNG" on the clipboard (most apps): read CF_DIB and convert.
// SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard.
let mut via_dib = false;
if wire == WIRE_PNG && unsafe { IsClipboardFormatAvailable(fmt) }.is_err() {
fmt = CF_DIB.0 as u32;
via_dib = true;
}
// If we own the clipboard, its content is our own delayed-render offer (the client's copy),
// not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT.
// SAFETY: GetClipboardOwner has no preconditions.
@@ -246,6 +256,9 @@ impl WinClip {
let _ = GlobalUnlock(hg);
buf
};
if via_dib {
return winfmt::dib_to_png(&raw).context("CF_DIB -> PNG conversion failed");
}
Ok(convert_from_win(wire, &raw))
}
@@ -267,7 +280,16 @@ impl WinClip {
Ok(b) => b,
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
};
let win_bytes = convert_to_win(wire, &bytes);
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) {
Some(d) => d,
None => return,
}
} else {
convert_to_win(wire, &bytes)
};
let Ok(hg) = alloc_hglobal(&win_bytes) else {
return;
};
@@ -300,7 +322,7 @@ impl WinClip {
Some(WIRE_HTML)
} else if fmt == self.fmt_rtf {
Some(WIRE_RTF)
} else if fmt == self.fmt_png {
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 {
Some(WIRE_PNG)
} else {
None
@@ -317,6 +339,12 @@ impl WinClip {
out.push(f);
}
}
// 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)) {
out.push(CF_DIB.0 as u32);
}
}
out
}
+109
View File
@@ -254,4 +254,113 @@ mod tests {
// Only one NUL is stripped.
assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0");
}
#[test]
fn png_dib_round_trip() {
// A 3x2 RGBA PNG with distinct corner pixels survives PNG -> DIB -> PNG.
let mut png = Vec::new();
let img = image::RgbaImage::from_fn(3, 2, |x, y| {
image::Rgba([x as u8 * 40, y as u8 * 80, 200, 255])
});
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");
// 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);
assert_eq!(i32::from_le_bytes(dib[8..12].try_into().unwrap()), 2);
let png2 = dib_to_png(&dib).expect("dib -> png");
let back = image::load_from_memory(&png2).unwrap().to_rgba8();
assert_eq!(back.dimensions(), (3, 2));
assert_eq!(back.get_pixel(2, 1), img.get_pixel(2, 1));
}
#[test]
fn dib_to_png_rejects_garbage() {
assert!(dib_to_png(&[0u8; 10]).is_none());
assert!(dib_to_png(&[0xFFu8; 200]).is_none());
}
}
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
//
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
// 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()?;
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 {
return None;
}
let mut out = Vec::with_capacity(40 + w * h * 4);
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
for row in rgba.rows().rev() {
for px in row {
let [r, g, b, a] = px.0;
out.extend_from_slice(&[b, g, r, a]);
}
}
Some(out)
}
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
/// any malformed input — the caller declines the fetch.
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
if dib.len() < 40 {
return None;
}
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
if hdr_size < 40 || hdr_size > dib.len() {
return None;
}
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
let palette = if bit_count <= 8 {
(if clr_used != 0 {
clr_used
} else {
1 << bit_count
}) * 4
} else if compression == 3 {
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
// V4/V5 headers already contain the masks within hdr_size).
if hdr_size == 40 {
12
} else {
0
}
} else {
0
};
let pixel_offset = 14 + hdr_size + palette;
let mut bmp = Vec::with_capacity(14 + dib.len());
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
bmp.extend_from_slice(dib);
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.ok()?;
Some(png)
}