fix(apple/clipboard): announce + serve image/png from ANY pasteboard image type
apple / swift (push) Successful in 1m20s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 1m4s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m43s
release / apple (push) Successful in 5m48s
docker / deploy-docs (push) Successful in 26s
apple / screenshots (push) Successful in 6m26s
android / android (push) Successful in 13m58s
arch / build-publish (push) Successful in 14m37s
ci / rust (push) Failing after 15m36s
deb / build-publish (push) Successful in 14m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m45s

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 1b58130e68
commit 78d018ae2f
@@ -202,9 +202,17 @@ public final class ClipboardSync: NSObject {
let types = pb.types ?? [] let types = pb.types ?? []
if types.contains(Self.concealed) || types.contains(Self.transient) { return } if types.contains(Self.concealed) || types.contains(Self.transient) { return }
offerSeq &+= 1 offerSeq &+= 1
let 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
// TIFF) and only sometimes PNG announce the portable `image/png` whenever ANY
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5).
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic"))
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
}
// Empty = the pasteboard holds nothing we sync (or was cleared) clears the host side. // Empty = the pasteboard holds nothing we sync (or was cleared) clears the host side.
connection.clipOffer(seq: offerSeq, kinds: kinds) connection.clipOffer(seq: offerSeq, kinds: kinds)
} }
@@ -305,8 +313,7 @@ public final class ClipboardSync: NSObject {
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) { private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
let pb = NSPasteboard.general let pb = NSPasteboard.general
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount, guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
let type = Self.wireToPasteboard.first(where: { $0.wire == mime })?.type, let data = Self.readWireData(pb, mime)
let data = pb.data(forType: type)
else { else {
connection.clipCancel(id: reqId) connection.clipCancel(id: reqId)
return return
@@ -322,6 +329,30 @@ public final class ClipboardSync: NSObject {
connection.clipServe(reqId: reqId, data: Data(), last: true) connection.clipServe(reqId: reqId, data: Data(), last: true)
} }
} }
/// Read one wire format from the pasteboard, converting where macOS stores a different
/// native type: `image/png` is served from a real `.png` entry when present, else converted
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
/// WebP/AVIF/GIF from browsers `NSImage` decodes them all) into PNG at fetch time.
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
guard mime == "image/png" else {
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
return nil
}
return pb.data(forType: type)
}
if let png = pb.data(forType: .png) {
return png
}
// No native PNG: decode whatever image the pasteboard carries and re-encode.
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
} }
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes; /// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;