From 565174c4b8c73e9e28524709060335a0b17ee3fb Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Mon, 29 Jun 2026 09:45:37 -0600 Subject: [PATCH 1/5] feat(NSPasteboard): added objective c bridge and build changes --- build.zig | 15 +++++++++++++++ src/Clip.zig | 3 +++ src/Clip/NSPBBridge.m | 12 ++++++++++++ src/Clip/NSPasteboard.zig | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 src/Clip/NSPBBridge.m create mode 100644 src/Clip/NSPasteboard.zig diff --git a/build.zig b/build.zig index 242a70c..cb43d76 100644 --- a/build.zig +++ b/build.zig @@ -23,6 +23,21 @@ pub fn build(b: *std.Build) void { }), }); + if (target.result.os.tag == .macos) { + exe.root_module.addCSourceFiles(.{ + .files = &.{"src/Clip/NSPBBridge.m"}, + .language = .objective_c, + .flags = &.{"-fobjc-arc"}, + }); + exe.root_module.linkFramework("Cocoa", .{}); + tests.root_module.addCSourceFiles(.{ + .files = &.{"src/Clip/NSPBBridge.m"}, + .language = .objective_c, + .flags = &.{"-fobjc-arc"}, + }); + tests.root_module.linkFramework("Cocoa", .{}); + } + const test_step = b.step("test", "Run tests"); const run_tests = b.addRunArtifact(tests); test_step.dependOn(&run_tests.step); diff --git a/src/Clip.zig b/src/Clip.zig index 8d264c8..1adeeb0 100644 --- a/src/Clip.zig +++ b/src/Clip.zig @@ -1,4 +1,5 @@ pub const OSC52 = @import("Clip/OSC52.zig"); +// pub const PBCopy = if (@import("builtin").os.tag == .macos) @import("Clip/OSC52.zig") else null; // I am not sure if this is super idiomatic Zig or if this is bad practice, but // by ensuring that all Clip backends implement the same methods, we are able @@ -15,6 +16,8 @@ fn assertFn(comptime T: type, comptime name: []const u8, comptime Sig: type) voi } test { + _ = @import("Clip/NSPasteBoard.zig"); + inline for (@typeInfo(@This()).@"struct".decls) |decl| { // Only grab types of structs const T = @field(@This(), decl.name); diff --git a/src/Clip/NSPBBridge.m b/src/Clip/NSPBBridge.m new file mode 100644 index 0000000..e736c3f --- /dev/null +++ b/src/Clip/NSPBBridge.m @@ -0,0 +1,12 @@ +// Simple bridge to objective_c code +// https://nathancraddock.com/blog/writing-to-the-clipboard-the-hard-way/ +#import +NSPasteboard *pboard; + +void initPB() { pboard = [NSPasteboard generalPasteboard]; } + +void sendPB(const char *text) { + [pboard clearContents]; + [pboard setString:[NSString stringWithUTF8String:text] + forType:NSPasteboardTypeString]; +} diff --git a/src/Clip/NSPasteboard.zig b/src/Clip/NSPasteboard.zig new file mode 100644 index 0000000..92c3403 --- /dev/null +++ b/src/Clip/NSPasteboard.zig @@ -0,0 +1,18 @@ +const std = @import("std"); + +extern fn sendPB(text: [*:0]const u8) void; +extern fn initPB() void; + +test "objc bridge" { + initPB(); + sendPB("test_string"); + + const result = try std.process.run(std.testing.allocator, std.testing.io, .{ + .argv = &.{"pbpaste"}, + }); + defer std.testing.allocator.free(result.stdout); + defer std.testing.allocator.free(result.stderr); + + try std.testing.expectEqualStrings("test_string", std.mem.trim(u8, result.stdout, "\n")); +} + From 9a377fa209cfecf53343247d197dc8578d6c2a1d Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Tue, 30 Jun 2026 10:02:29 -0600 Subject: [PATCH 2/5] feat(NSPasteboard): uses backend when not ssh and on MacOS --- src/Clip.zig | 9 ++++++--- src/Clip/NSPasteboard.zig | 37 ++++++++++++++++++++++++++++++++++++- src/main.zig | 29 ++++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/Clip.zig b/src/Clip.zig index 1adeeb0..b4e1187 100644 --- a/src/Clip.zig +++ b/src/Clip.zig @@ -1,5 +1,10 @@ +const std = @import("std"); +const builtin = @import("builtin"); + pub const OSC52 = @import("Clip/OSC52.zig"); -// pub const PBCopy = if (@import("builtin").os.tag == .macos) @import("Clip/OSC52.zig") else null; +// Ideally we would make else be `@compileError` but that +// messes up with the interface checking +pub const NSPasteboard = if (builtin.target.os.tag == .macos) @import("Clip/NSPasteboard.zig") else void; // I am not sure if this is super idiomatic Zig or if this is bad practice, but // by ensuring that all Clip backends implement the same methods, we are able @@ -16,8 +21,6 @@ fn assertFn(comptime T: type, comptime name: []const u8, comptime Sig: type) voi } test { - _ = @import("Clip/NSPasteBoard.zig"); - inline for (@typeInfo(@This()).@"struct".decls) |decl| { // Only grab types of structs const T = @field(@This(), decl.name); diff --git a/src/Clip/NSPasteboard.zig b/src/Clip/NSPasteboard.zig index 92c3403..acb895a 100644 --- a/src/Clip/NSPasteboard.zig +++ b/src/Clip/NSPasteboard.zig @@ -1,8 +1,44 @@ +/// NSPasteBoard Clipboard backend +/// Fulfills `Clip` interface +pub const NSPasteboard = @This(); + const std = @import("std"); extern fn sendPB(text: [*:0]const u8) void; extern fn initPB() void; +alloc: std.mem.Allocator, +io: std.Io, + +buffer: std.ArrayList(u8), + +pub fn init(alloc: std.mem.Allocator, io: std.Io, buf_size: usize) !NSPasteboard { + return .{ + .alloc = alloc, + .io = io, + .buffer = try std.ArrayList(u8).initCapacity(alloc, buf_size), + }; +} + +pub fn free(self: *NSPasteboard) void { + self.buffer.clearAndFree(self.alloc); +} + +pub fn writeCopyBuffer(self: *NSPasteboard, buf: []const u8) anyerror!void { + try self.buffer.appendSlice(self.alloc, buf); +} + +pub fn writePasteboard(self: *NSPasteboard) anyerror!void { + // Null Terminate string for C function Call + if (self.buffer.items[self.buffer.items.len - 1] == '\n') { + self.buffer.items[self.buffer.items.len - 1] = '\x00'; + } else { + try self.buffer.append(self.alloc, '\x00'); + } + initPB(); + sendPB(@ptrCast(self.buffer.items.ptr)); +} + test "objc bridge" { initPB(); sendPB("test_string"); @@ -15,4 +51,3 @@ test "objc bridge" { try std.testing.expectEqualStrings("test_string", std.mem.trim(u8, result.stdout, "\n")); } - diff --git a/src/main.zig b/src/main.zig index 4f45742..0725ae7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,7 @@ const std = @import("std"); +const builtin = @import("builtin"); + +const os = builtin.target.os.tag; const File = std.Io.File; const Clip = @import("Clip.zig"); @@ -13,10 +16,30 @@ pub fn main(init: std.process.Init) !void { // We want to avoid doing as many allocations as possible so having raw // pages with it being larger should be totally fine - var clip = try Clip.OSC52.init(std.heap.page_allocator, init.io, std.heap.pageSize()); - defer clip.free(); + const alloc = std.heap.page_allocator; + const size = std.heap.pageSize(); - try run(io, &clip); + const is_ssh = init.environ_map.get("SSH_CONNECTION") != null or + init.environ_map.get("SSH_CLIENT") != null or + init.environ_map.get("SSH_TTY") != null; + + // This can be cleaned up + if (is_ssh) { + var clip = try Clip.OSC52.init(alloc, io, size); + defer clip.free(); + try run(io, &clip); + } else { + if (comptime os == .macos) { + var clip = try Clip.NSPasteboard.init(alloc, io, size); + defer clip.free(); + try run(io, &clip); + } else { + // No Linux Clipboards yet + var clip = try Clip.OSC52.init(alloc, io, size); + defer clip.free(); + try run(io, &clip); + } + } } pub fn run(io: std.Io, clip: anytype) !void { From 8062e417f2c40cc45a422f8546b6dd76423cdb38 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Tue, 30 Jun 2026 10:44:34 -0600 Subject: [PATCH 3/5] build: added `zig build interface` for cross platform interface checking using comptime` --- build.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.zig b/build.zig index cb43d76..59c2f26 100644 --- a/build.zig +++ b/build.zig @@ -42,6 +42,10 @@ pub fn build(b: *std.Build) void { const run_tests = b.addRunArtifact(tests); test_step.dependOn(&run_tests.step); + const interface_step = b.step("interface", "Check interface (compile tests only)"); + const check_interface = b.addInstallArtifact(tests, .{}); + interface_step.dependOn(&check_interface.step); + const run_step = b.step("run", "Run the app"); const run_cmd = b.addRunArtifact(exe); From 63265d1d7e36c4150df3bdc00bfda2391a54b916 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Tue, 30 Jun 2026 14:32:23 -0600 Subject: [PATCH 4/5] misc: cleanup --- src/Clip.zig | 3 ++- src/Clip/OSC52.zig | 9 ++++----- src/main.zig | 34 ++++++++++++++++++++-------------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/Clip.zig b/src/Clip.zig index b4e1187..299d4c6 100644 --- a/src/Clip.zig +++ b/src/Clip.zig @@ -4,7 +4,8 @@ const builtin = @import("builtin"); pub const OSC52 = @import("Clip/OSC52.zig"); // Ideally we would make else be `@compileError` but that // messes up with the interface checking -pub const NSPasteboard = if (builtin.target.os.tag == .macos) @import("Clip/NSPasteboard.zig") else void; +pub const NSPasteboard = + if (builtin.target.os.tag == .macos) @import("Clip/NSPasteboard.zig") else void; // I am not sure if this is super idiomatic Zig or if this is bad practice, but // by ensuring that all Clip backends implement the same methods, we are able diff --git a/src/Clip/OSC52.zig b/src/Clip/OSC52.zig index d95fca1..8b751f5 100644 --- a/src/Clip/OSC52.zig +++ b/src/Clip/OSC52.zig @@ -3,7 +3,6 @@ pub const OSC52 = @This(); const std = @import("std"); -const Io = std.Io; alloc: std.mem.Allocator, io: std.Io, @@ -13,9 +12,10 @@ base64buffer: std.ArrayList(u8), leftover_bytes: [3]u8, leftover_count: u8, -const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, '='); +const base64encoder = + std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, '='); -pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { +pub fn init(alloc: std.mem.Allocator, io: std.Io, buf_size: usize) !OSC52 { return .{ .alloc = alloc, .io = io, @@ -51,7 +51,6 @@ pub fn writeCopyBuffer(self: *OSC52, buf: []const u8) anyerror!void { const chunkable_length = buf.len - ((buf.len - buf_idx) % 3); var chunker = std.mem.window(u8, buf[buf_idx..chunkable_length], 3, 3); while (chunker.next()) |chunk| { - // var temp: [5]u8 = undefined; s = OSC52.base64encoder.encode(&temp, chunk); try self.base64buffer.appendSlice(self.alloc, s); } @@ -87,7 +86,7 @@ pub fn writePasteboard(self: *OSC52) anyerror!void { }; // Write data to stdout - const stdout = Io.File.stdout(); + const stdout = std.Io.File.stdout(); try stdout.writeStreamingAll(self.io, osc_header); try stdout.writeStreamingAll(self.io, self.base64buffer.items); try stdout.writeStreamingAll(self.io, osc_footer); diff --git a/src/main.zig b/src/main.zig index 0725ae7..bb0aa14 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,11 @@ pub fn main(init: std.process.Init) !void { // This program should not be run interactively if (try File.stdin().isTty(io)) { - std.debug.print("This program is not meant to be run interactively. Please pipe into this program.\n", .{}); + std.debug.print( + \\ This program is not meant to be run interactively. + \\ Please pipe into this program. + \\ + , .{}); std.process.exit(1); } @@ -19,27 +23,29 @@ pub fn main(init: std.process.Init) !void { const alloc = std.heap.page_allocator; const size = std.heap.pageSize(); - const is_ssh = init.environ_map.get("SSH_CONNECTION") != null or + const is_ssh = + init.environ_map.get("SSH_CONNECTION") != null or init.environ_map.get("SSH_CLIENT") != null or init.environ_map.get("SSH_TTY") != null; - // This can be cleaned up if (is_ssh) { var clip = try Clip.OSC52.init(alloc, io, size); defer clip.free(); try run(io, &clip); - } else { - if (comptime os == .macos) { - var clip = try Clip.NSPasteboard.init(alloc, io, size); - defer clip.free(); - try run(io, &clip); - } else { - // No Linux Clipboards yet - var clip = try Clip.OSC52.init(alloc, io, size); - defer clip.free(); - try run(io, &clip); - } + return; } + + if (comptime os == .macos) { + var clip = try Clip.NSPasteboard.init(alloc, io, size); + defer clip.free(); + try run(io, &clip); + return; + } + + // Fallback Clipboard + var clip = try Clip.OSC52.init(alloc, io, size); + defer clip.free(); + try run(io, &clip); } pub fn run(io: std.Io, clip: anytype) !void { From 6eb7d639dac6cc9034168b8adf72832dfd20b484 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Tue, 30 Jun 2026 14:11:34 -0600 Subject: [PATCH 5/5] build: updated pipelines to use cargo-zigbuild container to work with apple and be faster --- .crow/build.yml | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/.crow/build.yml b/.crow/build.yml index e6945f6..44c059a 100644 --- a/.crow/build.yml +++ b/.crow/build.yml @@ -7,42 +7,33 @@ clone: settings: tags: true +variables: + - &zig_image 'ghcr.io/rust-cross/cargo-zigbuild' + steps: - name: build-linux-x86_64 - image: alpine:3.20 + image: *zig_image commands: - - apk add --no-cache curl xz - - curl -sSL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz | tar -xJ -C /usr/local - - ln -s /usr/local/zig-x86_64-linux-0.16.0/zig /usr/local/bin/zig - - zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe - - cp zig-out/bin/tpb tpb-linux-x86_64 + - zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe --prefix zig-out-linux-x86_64 + - cp zig-out-linux-x86_64/bin/tpb tpb-linux-x86_64 - name: build-linux-aarch64 - image: alpine:3.20 + image: *zig_image commands: - - apk add --no-cache curl xz - - curl -sSL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz | tar -xJ -C /usr/local - - ln -s /usr/local/zig-x86_64-linux-0.16.0/zig /usr/local/bin/zig - - zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe - - cp zig-out/bin/tpb tpb-linux-aarch64 + - zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe --prefix zig-out-linux-aarch64 + - cp zig-out-linux-aarch64/bin/tpb tpb-linux-aarch64 - name: build-macos-x86_64 - image: alpine:3.20 + image: *zig_image commands: - - apk add --no-cache curl xz - - curl -sSL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz | tar -xJ -C /usr/local - - ln -s /usr/local/zig-x86_64-linux-0.16.0/zig /usr/local/bin/zig - - zig build -Dtarget=x86_64-macos -Doptimize=ReleaseSafe - - cp zig-out/bin/tpb tpb-macos-x86_64 + - zig build -Dtarget=x86_64-macos -Doptimize=ReleaseSafe --prefix zig-out-macos-x86_64 + - cp zig-out-macos-x86_64/bin/tpb tpb-macos-x86_64 - name: build-macos-aarch64 - image: alpine:3.20 + image: *zig_image commands: - - apk add --no-cache curl xz - - curl -sSL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz | tar -xJ -C /usr/local - - ln -s /usr/local/zig-x86_64-linux-0.16.0/zig /usr/local/bin/zig - - zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe - - cp zig-out/bin/tpb tpb-macos-aarch64 + - zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe --prefix zig-out-macos-aarch64 + - cp zig-out-macos-aarch64/bin/tpb tpb-macos-aarch64 - name: release