From 63bd39070eb8cda875fb921d37a7a07f78a2510f Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:42:34 -0400 Subject: [PATCH 1/3] feat(paste): opt-in Cmd+V image passthrough Add a [paste] config section with image_passthrough (default false). When enabled and the macOS clipboard holds an image, Cmd+V forwards Ctrl+V (0x16) to the focused terminal instead of pasting text, so a CLI running there performs its own inline image paste. Plain text paste is unaffected. - config.zig: PasteConfig + default-config template + tests - platform/macos_clipboard.zig: NSPasteboard image detection via objc interop - app/terminal_actions.zig: tryPasteImagePassthrough sends 0x16 - app/runtime.zig: gate the Cmd+V handler on the flag --- src/app/runtime.zig | 17 +++++++++-- src/app/terminal_actions.zig | 24 +++++++++++++++ src/config.zig | 35 ++++++++++++++++++++++ src/platform/macos_clipboard.zig | 51 ++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/platform/macos_clipboard.zig diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb12..8b2a47f 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -2033,9 +2033,20 @@ pub fn run() !void { }; } else if (key == c.SDLK_V and has_gui and !has_blocking_mod) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘V", now); - terminal_actions.pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { - std.debug.print("Paste failed: {}\n", .{err}); - }; + // Opt-in: if the clipboard holds an image, forward Ctrl+V so a + // CLI in the terminal inlines it; otherwise paste text as usual. + var handled_paste = false; + if (config.paste.image_passthrough) { + handled_paste = terminal_actions.tryPasteImagePassthrough(focused, &ui, now) catch |err| blk: { + std.debug.print("Image paste passthrough failed: {}\n", .{err}); + break :blk false; + }; + } + if (!handled_paste) { + terminal_actions.pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { + std.debug.print("Paste failed: {}\n", .{err}); + }; + } } else if (input.fontSizeShortcut(key, mod)) |direction| { if (config.ui.show_hotkey_feedback) ui.showHotkey(if (direction == .increase) "⌘+" else "⌘-", now); const delta: c_int = if (direction == .increase) font_step else -font_step; diff --git a/src/app/terminal_actions.zig b/src/app/terminal_actions.zig index 40e2e06..0fe825f 100644 --- a/src/app/terminal_actions.zig +++ b/src/app/terminal_actions.zig @@ -3,6 +3,7 @@ const ghostty_vt = @import("ghostty-vt"); const session_state = @import("../session/state.zig"); const ui_mod = @import("../ui/mod.zig"); const c = @import("../c.zig"); +const macos_clipboard = @import("../platform/macos_clipboard.zig"); const SessionState = session_state.SessionState; const log = std.log.scoped(.terminal_actions); @@ -36,6 +37,29 @@ pub fn pasteText( } } +/// Cmd+V image passthrough (opt-in via `[paste] image_passthrough`). +/// +/// When the system clipboard holds an image, Architect can't paste the image +/// bytes itself (its paste path is text-only). Instead we forward the Ctrl+V +/// control byte (0x16) to the focused terminal so a CLI running there (e.g. +/// Claude Code) performs its own native inline image paste — matching VS Code's +/// Cmd+V behavior. +/// +/// Returns true if it handled the event (an image was present and 0x16 was +/// sent); false means there was no image and the caller should fall back to the +/// normal text paste. macOS only — `hasClipboardImage` is always false elsewhere. +pub fn tryPasteImagePassthrough( + session: *SessionState, + ui: *ui_mod.UiRoot, + now: i64, +) !bool { + if (!macos_clipboard.hasClipboardImage()) return false; + + try session.sendInput(&[_]u8{0x16}); // Ctrl+V + ui.showToast("Forwarded image paste (⌃V)", now); + return true; +} + pub fn clearTerminal(session: *SessionState) void { const terminal_ptr = session.terminal orelse return; var terminal = terminal_ptr; diff --git a/src/config.zig b/src/config.zig index 2acdf28..fca7a87 100644 --- a/src/config.zig +++ b/src/config.zig @@ -73,6 +73,14 @@ pub const UiConfig = struct { enable_animations: bool = true, }; +pub const PasteConfig = struct { + /// When true, pressing Cmd+V while the system clipboard holds an image + /// forwards Ctrl+V (0x16) to the focused terminal instead of pasting text, + /// so a CLI like Claude Code performs its own inline image paste. Plain + /// text paste is unaffected. macOS only. Default false. + image_passthrough: bool = false, +}; + pub const PaletteConfig = struct { black: ?[]const u8 = null, red: ?[]const u8 = null, @@ -779,6 +787,7 @@ pub const Config = struct { grid: GridConfig = .{}, theme: ThemeConfig = .{}, ui: UiConfig = .{}, + paste: PasteConfig = .{}, rendering: Rendering = .{}, metrics: MetricsConfig = .{}, logging: LoggingConfig = .{}, @@ -831,6 +840,12 @@ pub const Config = struct { \\# show_hotkey_feedback = true \\# enable_animations = true \\ + \\# Paste options + \\# [paste] + \\# image_passthrough = false # When true, Cmd+V forwards Ctrl+V to the + \\# # terminal if the clipboard holds an image, + \\# # so a CLI (e.g. Claude Code) inlines it. + \\ \\# Theme colors (hex format) \\# [theme] \\# background = "#0E1116" @@ -1050,6 +1065,26 @@ test "Config - decode sectioned toml" { try std.testing.expectEqual(std.log.Level.warn, config.logging.getMinLevel()); try std.testing.expectEqual(false, config.ui.show_hotkey_feedback); try std.testing.expectEqual(false, config.ui.enable_animations); + // [paste] omitted above -> defaults to disabled. + try std.testing.expectEqual(false, config.paste.image_passthrough); +} + +test "Config - parses [paste] image_passthrough" { + const allocator = std.testing.allocator; + + const content = + \\[paste] + \\image_passthrough = true + \\ + ; + + var parser = toml.Parser(Config).init(allocator); + defer parser.deinit(); + + var result = try parser.parseString(content); + defer result.deinit(); + + try std.testing.expectEqual(true, result.value.paste.image_passthrough); } test "LoggingConfig.getMinLevel falls back to info for unknown values" { diff --git a/src/platform/macos_clipboard.zig b/src/platform/macos_clipboard.zig new file mode 100644 index 0000000..0ddc198 --- /dev/null +++ b/src/platform/macos_clipboard.zig @@ -0,0 +1,51 @@ +// macOS clipboard image detection. +// +// Used by the Cmd+V image-passthrough feature: when the general pasteboard +// holds image data (a screenshot, a copied PNG/TIFF, etc.), Architect forwards +// Ctrl+V to the focused terminal so a CLI like Claude Code performs its own +// inline image paste, instead of pasting clipboard text. +// +// Implemented with the same objc_msgSend interop pattern as +// platform/macos_input_source.zig. The AppKit classes (NSPasteboard, NSImage) +// are looked up by name at runtime via objc_getClass, so no AppKit headers are +// needed here — AppKit is already linked by build.zig. + +const builtin = @import("builtin"); + +const is_macos = builtin.os.tag == .macos; + +const Impl = if (is_macos) struct { + const c = @cImport({ + @cInclude("objc/runtime.h"); + @cInclude("objc/message.h"); + }); + + // objc_msgSend has no single C prototype; cast it per call signature. + const MsgSend = *const fn (?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; + const MsgSendBoolArg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) u8; + + fn hasClipboardImage() bool { + const ns_pasteboard = c.objc_getClass("NSPasteboard") orelse return false; + const ns_image = c.objc_getClass("NSImage") orelse return false; + const sel_general = c.sel_registerName("generalPasteboard") orelse return false; + const sel_can = c.sel_registerName("canInitWithPasteboard:") orelse return false; + + // [NSPasteboard generalPasteboard] -> shared pasteboard (not owned by us). + const msg_send = @as(MsgSend, @ptrCast(&c.objc_msgSend)); + const pasteboard = msg_send(ns_pasteboard, sel_general) orelse return false; + + // +[NSImage canInitWithPasteboard:pasteboard] -> BOOL. + const msg_send_bool = @as(MsgSendBoolArg, @ptrCast(&c.objc_msgSend)); + return msg_send_bool(ns_image, sel_can, pasteboard) != 0; + } +} else struct { + fn hasClipboardImage() bool { + return false; + } +}; + +/// Returns true if the macOS general pasteboard currently holds image data +/// (PNG/TIFF/PDF) that NSImage could read. Always false on non-macOS targets. +pub fn hasClipboardImage() bool { + return Impl.hasClipboardImage(); +} From afb847e61b0f1f279e7c3eec431376e4db7731dd Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:50:28 -0400 Subject: [PATCH 2/3] fix(paste): detect clipboard images via canReadObjectForClasses Replace +[NSImage canInitWithPasteboard:] with -[NSPasteboard canReadObjectForClasses:@[NSImage] options:nil] for clipboard image detection. canInitWithPasteboard: only matches the legacy NeXT pasteboard type set: it missed images declaring only modern UTIs (some Universal Clipboard / iPhone screenshots) and false-positived on any file URL. The modern UTI-aware API matches public.image-conforming types (incl. Universal Clipboard) and image file URLs while rejecting non-image file URLs, and is metadata-only so it does not pull promised bytes. --- src/platform/macos_clipboard.zig | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/platform/macos_clipboard.zig b/src/platform/macos_clipboard.zig index 0ddc198..794e3c7 100644 --- a/src/platform/macos_clipboard.zig +++ b/src/platform/macos_clipboard.zig @@ -22,21 +22,37 @@ const Impl = if (is_macos) struct { // objc_msgSend has no single C prototype; cast it per call signature. const MsgSend = *const fn (?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; - const MsgSendBoolArg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) u8; + const MsgSendIdArg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; + const MsgSendBool2Arg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) u8; fn hasClipboardImage() bool { const ns_pasteboard = c.objc_getClass("NSPasteboard") orelse return false; const ns_image = c.objc_getClass("NSImage") orelse return false; + const ns_array = c.objc_getClass("NSArray") orelse return false; const sel_general = c.sel_registerName("generalPasteboard") orelse return false; - const sel_can = c.sel_registerName("canInitWithPasteboard:") orelse return false; + const sel_array_with = c.sel_registerName("arrayWithObject:") orelse return false; + const sel_can_read = c.sel_registerName("canReadObjectForClasses:options:") orelse return false; - // [NSPasteboard generalPasteboard] -> shared pasteboard (not owned by us). const msg_send = @as(MsgSend, @ptrCast(&c.objc_msgSend)); + const msg_send_id = @as(MsgSendIdArg, @ptrCast(&c.objc_msgSend)); + const msg_send_bool2 = @as(MsgSendBool2Arg, @ptrCast(&c.objc_msgSend)); + + // [NSPasteboard generalPasteboard] -> shared pasteboard (not owned by us). const pasteboard = msg_send(ns_pasteboard, sel_general) orelse return false; - // +[NSImage canInitWithPasteboard:pasteboard] -> BOOL. - const msg_send_bool = @as(MsgSendBoolArg, @ptrCast(&c.objc_msgSend)); - return msg_send_bool(ns_image, sel_can, pasteboard) != 0; + // classes = [NSArray arrayWithObject:[NSImage class]]. A Class doubles as + // the `id` element; canReadObjectForClasses: wants NSPasteboardReading + // classes, and NSImage conforms. + const classes = msg_send_id(ns_array, sel_array_with, ns_image) orelse return false; + + // [pasteboard canReadObjectForClasses:classes options:nil] -> BOOL. + // Unlike +[NSImage canInitWithPasteboard:] (which only matches the legacy + // NeXT pasteboard type set), this is UTI-aware: it matches any + // public.image-conforming type, including Universal Clipboard / iPhone + // screenshots that declare modern UTIs, and image file URLs, while + // rejecting non-image file URLs. It inspects declared types only, so it + // does not pull promised (lazily transferred) Universal Clipboard bytes. + return msg_send_bool2(pasteboard, sel_can_read, classes, null) != 0; } } else struct { fn hasClipboardImage() bool { @@ -45,7 +61,9 @@ const Impl = if (is_macos) struct { }; /// Returns true if the macOS general pasteboard currently holds image data -/// (PNG/TIFF/PDF) that NSImage could read. Always false on non-macOS targets. +/// NSImage can read — PNG/TIFF/JPEG/HEIC/PDF, including Universal Clipboard / +/// iPhone screenshots (modern UTIs) and image file URLs, while rejecting +/// non-image file URLs. Always false on non-macOS targets. pub fn hasClipboardImage() bool { return Impl.hasClipboardImage(); } From b8855ec34f7f2ce56db3f17ba8d90b53ed707e57 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:55:36 -0400 Subject: [PATCH 3/3] feat(paste): paste clipboard images as a temp-file path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forwarding Ctrl+V relied on the CLI's own clipboard read, which is unreliable: Claude Code reads via a synchronous osascript call that fails on Universal Clipboard promised data and large images, inserting an empty placeholder. Now Architect reads the clipboard image as PNG bytes itself, writes a temp file, and pastes that path as text — CLIs like Claude Code attach image file paths reliably. macos_clipboard gains readClipboardImagePng (reads public.png, or converts the TIFF representation via NSBitmapImageRep as a fallback). --- src/app/runtime.zig | 2 +- src/app/terminal_actions.zig | 53 +++++++++++++++++++----- src/config.zig | 13 +++--- src/platform/macos_clipboard.zig | 71 ++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 8b2a47f..7dc216d 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -2037,7 +2037,7 @@ pub fn run() !void { // CLI in the terminal inlines it; otherwise paste text as usual. var handled_paste = false; if (config.paste.image_passthrough) { - handled_paste = terminal_actions.tryPasteImagePassthrough(focused, &ui, now) catch |err| blk: { + handled_paste = terminal_actions.tryPasteImagePassthrough(focused, allocator, &ui, now, session_interaction_component) catch |err| blk: { std.debug.print("Image paste passthrough failed: {}\n", .{err}); break :blk false; }; diff --git a/src/app/terminal_actions.zig b/src/app/terminal_actions.zig index 0fe825f..303ae1a 100644 --- a/src/app/terminal_actions.zig +++ b/src/app/terminal_actions.zig @@ -37,29 +37,60 @@ pub fn pasteText( } } -/// Cmd+V image passthrough (opt-in via `[paste] image_passthrough`). +/// Cmd+V image paste (opt-in via `[paste] image_passthrough`). /// -/// When the system clipboard holds an image, Architect can't paste the image -/// bytes itself (its paste path is text-only). Instead we forward the Ctrl+V -/// control byte (0x16) to the focused terminal so a CLI running there (e.g. -/// Claude Code) performs its own native inline image paste — matching VS Code's -/// Cmd+V behavior. +/// When the system clipboard holds an image, Architect reads it, writes it to a +/// temp PNG file, and pastes that file path as text into the focused terminal. +/// CLIs like Claude Code recognize an image file path and attach the image. This +/// is far more reliable than forwarding Ctrl+V and relying on the program's own +/// clipboard read, which can't materialize promised (lazily transferred) +/// Universal Clipboard data and fails on large images. /// -/// Returns true if it handled the event (an image was present and 0x16 was -/// sent); false means there was no image and the caller should fall back to the -/// normal text paste. macOS only — `hasClipboardImage` is always false elsewhere. +/// Returns true if it handled the event (an image was read and its path pasted); +/// false means there was no readable image and the caller should fall back to +/// the normal text paste. macOS only — `readClipboardImagePng` is null elsewhere. pub fn tryPasteImagePassthrough( session: *SessionState, + allocator: std.mem.Allocator, ui: *ui_mod.UiRoot, now: i64, + session_interaction: *ui_mod.SessionInteractionComponent, ) !bool { if (!macos_clipboard.hasClipboardImage()) return false; - try session.sendInput(&[_]u8{0x16}); // Ctrl+V - ui.showToast("Forwarded image paste (⌃V)", now); + // Detected an image but couldn't read its bytes -> let the caller fall back + // to the normal text paste rather than swallowing the event. + const png = macos_clipboard.readClipboardImagePng(allocator) orelse return false; + defer allocator.free(png); + + const path = writeClipboardImageTempFile(allocator, png, session.id, now) catch |err| { + log.warn("session {d}: failed to write clipboard image temp file: {}", .{ session.id, err }); + return false; + }; + defer allocator.free(path); + + try pasteText(session, allocator, path, session_interaction); + ui.showToast("Pasted image as file path", now); return true; } +/// Writes clipboard image PNG bytes to a uniquely-named temp file and returns +/// the absolute path (caller owns it). The file is intentionally left on disk so +/// the CLI can read it when the message is sent; the OS reclaims the temp dir. +fn writeClipboardImageTempFile(allocator: std.mem.Allocator, png: []const u8, session_id: usize, now: i64) ![]u8 { + const tmp_dir = std.posix.getenv("TMPDIR") orelse "/tmp"; + const name = try std.fmt.allocPrint(allocator, "architect-paste-{d}-{d}.png", .{ now, session_id }); + defer allocator.free(name); + const path = try std.fs.path.join(allocator, &.{ tmp_dir, name }); + errdefer allocator.free(path); + + const file = try std.fs.createFileAbsolute(path, .{}); + defer file.close(); + try file.writeAll(png); + + return path; +} + pub fn clearTerminal(session: *SessionState) void { const terminal_ptr = session.terminal orelse return; var terminal = terminal_ptr; diff --git a/src/config.zig b/src/config.zig index fca7a87..46718eb 100644 --- a/src/config.zig +++ b/src/config.zig @@ -75,9 +75,9 @@ pub const UiConfig = struct { pub const PasteConfig = struct { /// When true, pressing Cmd+V while the system clipboard holds an image - /// forwards Ctrl+V (0x16) to the focused terminal instead of pasting text, - /// so a CLI like Claude Code performs its own inline image paste. Plain - /// text paste is unaffected. macOS only. Default false. + /// writes the image to a temp PNG and pastes that file path into the focused + /// terminal, so a CLI like Claude Code attaches the image. Plain text paste + /// is unaffected. macOS only. Default false. image_passthrough: bool = false, }; @@ -842,9 +842,10 @@ pub const Config = struct { \\ \\# Paste options \\# [paste] - \\# image_passthrough = false # When true, Cmd+V forwards Ctrl+V to the - \\# # terminal if the clipboard holds an image, - \\# # so a CLI (e.g. Claude Code) inlines it. + \\# image_passthrough = false # When true and the clipboard holds an + \\# # image, Cmd+V writes a temp PNG and pastes + \\# # its path so a CLI (e.g. Claude Code) + \\# # attaches the image. \\ \\# Theme colors (hex format) \\# [theme] diff --git a/src/platform/macos_clipboard.zig b/src/platform/macos_clipboard.zig index 794e3c7..244ea04 100644 --- a/src/platform/macos_clipboard.zig +++ b/src/platform/macos_clipboard.zig @@ -10,6 +10,7 @@ // are looked up by name at runtime via objc_getClass, so no AppKit headers are // needed here — AppKit is already linked by build.zig. +const std = @import("std"); const builtin = @import("builtin"); const is_macos = builtin.os.tag == .macos; @@ -24,6 +25,8 @@ const Impl = if (is_macos) struct { const MsgSend = *const fn (?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; const MsgSendIdArg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; const MsgSendBool2Arg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) u8; + const MsgSendUInt = *const fn (?*anyopaque, ?*anyopaque) callconv(.c) usize; + const MsgSendRepType = *const fn (?*anyopaque, ?*anyopaque, usize, ?*anyopaque) callconv(.c) ?*anyopaque; fn hasClipboardImage() bool { const ns_pasteboard = c.objc_getClass("NSPasteboard") orelse return false; @@ -54,10 +57,69 @@ const Impl = if (is_macos) struct { // does not pull promised (lazily transferred) Universal Clipboard bytes. return msg_send_bool2(pasteboard, sel_can_read, classes, null) != 0; } + + fn cstrArg(s: [*:0]const u8) ?*anyopaque { + return @ptrCast(@constCast(s)); + } + + // data = [pasteboard dataForType:[NSString stringWithUTF8String:type_name]] + fn pasteboardData( + pasteboard: ?*anyopaque, + ns_string: ?*anyopaque, + sel_string_utf8: ?*anyopaque, + sel_data_for_type: ?*anyopaque, + type_name: [*:0]const u8, + ) ?*anyopaque { + const msg_send_id = @as(MsgSendIdArg, @ptrCast(&c.objc_msgSend)); + const type_str = msg_send_id(ns_string, sel_string_utf8, cstrArg(type_name)) orelse return null; + return msg_send_id(pasteboard, sel_data_for_type, type_str); + } + + fn readClipboardImagePng(allocator: std.mem.Allocator) ?[]u8 { + const ns_pasteboard = c.objc_getClass("NSPasteboard") orelse return null; + const ns_string = c.objc_getClass("NSString") orelse return null; + const ns_bitmap = c.objc_getClass("NSBitmapImageRep") orelse return null; + const sel_general = c.sel_registerName("generalPasteboard") orelse return null; + const sel_string_utf8 = c.sel_registerName("stringWithUTF8String:") orelse return null; + const sel_data_for_type = c.sel_registerName("dataForType:") orelse return null; + const sel_imagerep_data = c.sel_registerName("imageRepWithData:") orelse return null; + const sel_rep_using = c.sel_registerName("representationUsingType:properties:") orelse return null; + const sel_bytes = c.sel_registerName("bytes") orelse return null; + const sel_length = c.sel_registerName("length") orelse return null; + + const msg_send = @as(MsgSend, @ptrCast(&c.objc_msgSend)); + const msg_send_id = @as(MsgSendIdArg, @ptrCast(&c.objc_msgSend)); + const msg_send_uint = @as(MsgSendUInt, @ptrCast(&c.objc_msgSend)); + const msg_send_rep = @as(MsgSendRepType, @ptrCast(&c.objc_msgSend)); + + const pasteboard = msg_send(ns_pasteboard, sel_general) orelse return null; + + // Prefer the ready-made PNG representation — screenshots, browser copies, + // and Universal Clipboard transfers all provide public.png directly. + var png_data = pasteboardData(pasteboard, ns_string, sel_string_utf8, sel_data_for_type, "public.png"); + + // Fallback: convert the TIFF representation to PNG via NSBitmapImageRep. + if (png_data == null) { + const tiff = pasteboardData(pasteboard, ns_string, sel_string_utf8, sel_data_for_type, "public.tiff") orelse return null; + const rep = msg_send_id(ns_bitmap, sel_imagerep_data, tiff) orelse return null; + png_data = msg_send_rep(rep, sel_rep_using, 4, null); // 4 = NSBitmapImageFileTypePNG + } + const png = png_data orelse return null; + + const len = msg_send_uint(png, sel_length); + if (len == 0) return null; + const bytes_ptr = msg_send(png, sel_bytes) orelse return null; + const src = @as([*]const u8, @ptrCast(bytes_ptr))[0..len]; + return allocator.dupe(u8, src) catch null; + } } else struct { fn hasClipboardImage() bool { return false; } + + fn readClipboardImagePng(_: std.mem.Allocator) ?[]u8 { + return null; + } }; /// Returns true if the macOS general pasteboard currently holds image data @@ -67,3 +129,12 @@ const Impl = if (is_macos) struct { pub fn hasClipboardImage() bool { return Impl.hasClipboardImage(); } + +/// Reads the macOS general pasteboard's image as PNG bytes (caller owns the +/// returned slice). Returns null when there is no image, the bytes can't be +/// materialized, or on non-macOS targets. This pulls promised (lazily +/// transferred) Universal Clipboard data, so it may briefly block on the +/// cross-device fetch — unlike hasClipboardImage(), which is metadata-only. +pub fn readClipboardImagePng(allocator: std.mem.Allocator) ?[]u8 { + return Impl.readClipboardImagePng(allocator); +}