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
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:
@@ -20,6 +20,9 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
quinn = "0.11"
|
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.
|
||||||
|
# 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]
|
[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
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ use ::windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
|||||||
use ::windows::Win32::System::Memory::{
|
use ::windows::Win32::System::Memory::{
|
||||||
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT,
|
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::{
|
use ::windows::Win32::UI::WindowsAndMessaging::{
|
||||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
|
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
|
||||||
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
|
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
|
||||||
@@ -131,7 +131,10 @@ impl WinClip {
|
|||||||
if avail(self.fmt_rtf) {
|
if avail(self.fmt_rtf) {
|
||||||
out.push(WIRE_RTF.to_string());
|
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.push(WIRE_PNG.to_string());
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -219,9 +222,16 @@ impl WinClip {
|
|||||||
|
|
||||||
/// Read one wire format of the current host selection (a client fetch).
|
/// Read one wire format of the current host selection (a client fetch).
|
||||||
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
|
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
let fmt = self
|
let mut fmt = self
|
||||||
.format_for_wire(wire)
|
.format_for_wire(wire)
|
||||||
.context("unsupported wire MIME")?;
|
.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),
|
// 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.
|
// not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT.
|
||||||
// SAFETY: GetClipboardOwner has no preconditions.
|
// SAFETY: GetClipboardOwner has no preconditions.
|
||||||
@@ -246,6 +256,9 @@ impl WinClip {
|
|||||||
let _ = GlobalUnlock(hg);
|
let _ = GlobalUnlock(hg);
|
||||||
buf
|
buf
|
||||||
};
|
};
|
||||||
|
if via_dib {
|
||||||
|
return winfmt::dib_to_png(&raw).context("CF_DIB -> PNG conversion failed");
|
||||||
|
}
|
||||||
Ok(convert_from_win(wire, &raw))
|
Ok(convert_from_win(wire, &raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +280,16 @@ impl WinClip {
|
|||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
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 {
|
let Ok(hg) = alloc_hglobal(&win_bytes) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -300,7 +322,7 @@ 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 {
|
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 {
|
||||||
Some(WIRE_PNG)
|
Some(WIRE_PNG)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -317,6 +339,12 @@ impl WinClip {
|
|||||||
out.push(f);
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,4 +254,113 @@ mod tests {
|
|||||||
// Only one NUL is stripped.
|
// Only one NUL is stripped.
|
||||||
assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0");
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user