From c566d83978c263ab1ada66192e1bc19704609467 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 17 Jun 2026 09:47:42 -0600 Subject: [PATCH 01/14] init: create tpb --- .gitignore | 17 +++++++++++ LICENSE | 22 ++++++++++++++ README.md | 15 ++++++++++ build.zig | 27 +++++++++++++++++ build.zig.zon | 13 +++++++++ src/main.zig | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d3982c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# This file is for zig-specific build artifacts. If you have OS-specific or +# editor-specific files to ignore, such as *.swp or .DS_Store, put those in +# your global ~/.gitignore and put this in your ~/.gitconfig: +# +# [core] +# excludesfile = ~/.gitignore +# +# Cheers! +# -andrewrk + +.zig-cache/ +zig-out/ +/release/ +/debug/ +/build/ +/build-*/ +/docgen_tmp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b66f4c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright 2026 Grace Yoder + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8eeb08 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# TPB + +> Pronounced `tee pee bee` or `tee pasteboard` + +TPB is a super simple, small program to "tee" into my clipboard using OSC 52 +codes. + +> Usage: `command | tpb` + +I was somewhat fed up with `pbcopy` eating stdout making it impossible to see +the output of what I was doing without pasting it. While, yes, this could just +be a shell script, I decided to write in a systems language because its more fun +and I like the idea of it being super low latency. Originally, I was going to +write this in Rust but decided that Zig was a better fit for something like +this. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..4e2fbe0 --- /dev/null +++ b/build.zig @@ -0,0 +1,27 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const exe = b.addExecutable(.{ + .name = "tpb", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..304943f --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .tpb, + .version = "0.0.1", + .fingerprint = 0x203a601c46df0265, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + }, +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..cd716ab --- /dev/null +++ b/src/main.zig @@ -0,0 +1,80 @@ +const std = @import("std"); +const File = std.Io.File; + +pub fn main(init: std.process.Init) !void { + const io = init.io; + + // 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.process.exit(1); + } + + std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); + + // We want to avoid doing as many allocations as possible so having raw + // pages with it being larger should be totally fine + const alloc = std.heap.page_allocator; + + var buf: [4096]u8 = undefined; + var copy_index: u8 = 0; + + var encoded_buffer = try std.ArrayList(u8).initCapacity(alloc, std.heap.pageSize()); + var encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); + + while (true) { + // Read as much of stdin there is + const bytes = File.stdin().readStreaming(io, &.{buf[copy_index..]}) catch |err| { + if (err == File.ReadStreamingError.EndOfStream) { + break; + } else { + return err; + } + }; + const length = copy_index + bytes; + + // Passthrough to stdout + _ = try File.stdout().writeStreamingAll(io, buf[copy_index..length]); + + // Base64 encode + const chunkable_length = length - (length % 3); + var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + while (chunker.next()) |chunk| { + var temp: [5]u8 = undefined; + const s = encoder.encode(&temp, chunk); + try encoded_buffer.appendSlice(alloc, s); + } + + // Move left over bits to the start of the array + copy_index = @intCast(length - chunkable_length); + if (copy_index != 0) { + @memmove(buf[0..copy_index], buf[chunkable_length..length]); + } + } + // Encode last bytes + var temp: [5]u8 = undefined; + const s = encoder.encode(&temp, buf[0..copy_index]); + try encoded_buffer.appendSlice(alloc, s); + + // OSC 52 Copying — written as one contiguous buffer so the terminal + // receives the complete sequence in a single write. + // https://ghostty.org/docs/vt/osc/52 + const osc_header = &[_]u8{ + 0x1b, // ESC + 0x5d, // ']' + 0x35, 0x32, // Code 52 + 0x3b, // ';' + // 0x70, // 'p' Primary Clipboard + 0x63, // 'c' Standard Clipboard + 0x3b, // ';' + }; + const osc_footer = &[_]u8{ + 0x1b, // ESC + 0x5c, // '\' + }; + + _ = try File.stdout().writeStreamingAll(io, osc_header); + _ = try File.stdout().writeStreamingAll(io, encoded_buffer.items); + _ = try File.stdout().writeStreamingAll(io, osc_footer); + std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); +} From e2c08a093f2ea627155c59c8f34524c36e711588 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 17:48:16 -0600 Subject: [PATCH 02/14] refactor: split OSC52 off into different interface --- src/Clip.zig | 1 + src/Clip/OSC52.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 54 ++++------------------------ 3 files changed, 97 insertions(+), 47 deletions(-) create mode 100644 src/Clip.zig create mode 100644 src/Clip/OSC52.zig diff --git a/src/Clip.zig b/src/Clip.zig new file mode 100644 index 0000000..7d2f6db --- /dev/null +++ b/src/Clip.zig @@ -0,0 +1 @@ +pub const OSC52 = @import("Clip/OSC52.zig"); diff --git a/src/Clip/OSC52.zig b/src/Clip/OSC52.zig new file mode 100644 index 0000000..44d92bf --- /dev/null +++ b/src/Clip/OSC52.zig @@ -0,0 +1,89 @@ +/// OSC52 Clipboard backend +/// Fullfils `Clip` interface +pub const OSC52 = @This(); + +const std = @import("std"); +const Io = std.Io; + +alloc: std.mem.Allocator, +io: std.Io, + +base64buffer: std.ArrayList(u8), + +leftover_bytes: [3]u8, +leftover_count: u8, + +const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); + +pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { + return .{ + .alloc = alloc, + .io = io, + .base64buffer = try std.ArrayList(u8).initCapacity(alloc, buf_size), + .leftover_bytes = undefined, + .leftover_count = 0, + }; +} + +pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { + if (buf.len == 0) return; + + // Start of buffer after dealing with leftover bytes + const buf_idx = 3 - self.leftover_count; + + if (buf.len + self.leftover_count < 3) { + const len = buf.len + self.leftover_count; + @memcpy(self.leftover_bytes[self.leftover_count..len], buf[0..buf.len]); + self.leftover_count = @intCast(len); + return; + } + + if (self.leftover_count != 0) { + @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); + try self.base64buffer.appendSlice(self.alloc, s); + } + + const chunkable_length = buf.len - ((buf.len - buf_idx) % 3); + var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + while (chunker.next()) |chunk| { + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, chunk); + try self.base64buffer.appendSlice(self.alloc, s); + } + + self.leftover_count = @intCast(buf.len - chunkable_length); + if (self.leftover_count != 0) { + @memmove(self.leftover_bytes[0..self.leftover_count], buf[chunkable_length..]); + } +} + +pub fn writePasteboard(self: *OSC52) !void { + + // https://ghostty.org/docs/vt/osc/52 + const osc_header = &[_]u8{ + 0x1b, // ESC + 0x5d, // ']' + 0x35, 0x32, // Code 52 + 0x3b, // ';' + // 0x70, // 'p' Primary Clipboard + 0x63, // 'c' Standard Clipboard + 0x3b, // ';' + }; + const osc_footer = &[_]u8{ + 0x1b, // ESC + 0x5c, // '\' + }; + + // Write leftover bytes + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); + try self.base64buffer.appendSlice(self.alloc, s); + + // Write data to stdout + const stdout = 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 cd716ab..5c1a339 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const File = std.Io.File; +const Clip = @import("Clip.zig"); pub fn main(init: std.process.Init) !void { const io = init.io; @@ -14,67 +15,26 @@ 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 - const alloc = std.heap.page_allocator; + var clip = try Clip.OSC52.init(std.heap.page_allocator, init.io, std.heap.pageSize()); var buf: [4096]u8 = undefined; - var copy_index: u8 = 0; - - var encoded_buffer = try std.ArrayList(u8).initCapacity(alloc, std.heap.pageSize()); - var encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); - while (true) { // Read as much of stdin there is - const bytes = File.stdin().readStreaming(io, &.{buf[copy_index..]}) catch |err| { + const bytes = File.stdin().readStreaming(io, &.{&buf}) catch |err| { if (err == File.ReadStreamingError.EndOfStream) { break; } else { return err; } }; - const length = copy_index + bytes; - // Passthrough to stdout - _ = try File.stdout().writeStreamingAll(io, buf[copy_index..length]); + _ = try File.stdout().writeStreamingAll(io, buf[0..bytes]); - // Base64 encode - const chunkable_length = length - (length % 3); - var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); - while (chunker.next()) |chunk| { - var temp: [5]u8 = undefined; - const s = encoder.encode(&temp, chunk); - try encoded_buffer.appendSlice(alloc, s); - } - - // Move left over bits to the start of the array - copy_index = @intCast(length - chunkable_length); - if (copy_index != 0) { - @memmove(buf[0..copy_index], buf[chunkable_length..length]); - } + // Write to clip + try clip.writeCopyBuffer(buf[0..bytes]); } - // Encode last bytes - var temp: [5]u8 = undefined; - const s = encoder.encode(&temp, buf[0..copy_index]); - try encoded_buffer.appendSlice(alloc, s); - // OSC 52 Copying — written as one contiguous buffer so the terminal - // receives the complete sequence in a single write. - // https://ghostty.org/docs/vt/osc/52 - const osc_header = &[_]u8{ - 0x1b, // ESC - 0x5d, // ']' - 0x35, 0x32, // Code 52 - 0x3b, // ';' - // 0x70, // 'p' Primary Clipboard - 0x63, // 'c' Standard Clipboard - 0x3b, // ';' - }; - const osc_footer = &[_]u8{ - 0x1b, // ESC - 0x5c, // '\' - }; + try clip.writePasteboard(); - _ = try File.stdout().writeStreamingAll(io, osc_header); - _ = try File.stdout().writeStreamingAll(io, encoded_buffer.items); - _ = try File.stdout().writeStreamingAll(io, osc_footer); std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); } From 4d5cfd372d446c93b90897fcb3417604eae76de7 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 18:15:37 -0600 Subject: [PATCH 03/14] feat: crow ci --- .crow/build.yml | 60 +++++++++++++++++++++++++++++++++++++++++++++++ .crow/release.yml | 23 ++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 .crow/build.yml create mode 100644 .crow/release.yml diff --git a/.crow/build.yml b/.crow/build.yml new file mode 100644 index 0000000..e6945f6 --- /dev/null +++ b/.crow/build.yml @@ -0,0 +1,60 @@ +when: + - event: tag + +clone: + git: + image: codefloe.com/crow-plugins/clone:1.1.0 + settings: + tags: true + +steps: + - name: build-linux-x86_64 + image: alpine:3.20 + 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 + + - name: build-linux-aarch64 + image: alpine:3.20 + 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 + + - name: build-macos-x86_64 + image: alpine:3.20 + 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 + + - name: build-macos-aarch64 + image: alpine:3.20 + 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 + + + - name: release + image: woodpeckerci/plugin-release + depends_on: [build-linux-x86_64, build-linux-aarch64, build-macos-x86_64, build-macos-aarch64] + settings: + api_key: + from_secret: forgejo_token + files: + - tpb-linux-x86_64 + - tpb-linux-aarch64 + - tpb-macos-x86_64 + - tpb-macos-aarch64 + checksum: sha256 + file_exists: overwrite diff --git a/.crow/release.yml b/.crow/release.yml new file mode 100644 index 0000000..3770f56 --- /dev/null +++ b/.crow/release.yml @@ -0,0 +1,23 @@ +# This file is a bit of a hack in order to run the real release CI +when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + +clone: + git: + image: codefloe.com/crow-plugins/clone:1.1.0 + settings: + tags: true + + +steps: + - name: retag-latest + image: alpine:3.20 + environment: + FORGEJO_TOKEN: + from_secret: forgejo_token + commands: + - apk add --no-cache git + - git remote set-url origin "https://oauth2:$${FORGEJO_TOKEN}@$${CI_FORGE_URL#https://}/$${CI_REPO}.git" + - git tag -f latest + - git push -f origin latest From 62c89c5bf22d7e71f7eaaaadb7d50b81947b0ee6 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 19:15:20 -0600 Subject: [PATCH 04/14] docs: add releases link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e8eeb08..7893341 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ TPB is a super simple, small program to "tee" into my clipboard using OSC 52 codes. +[Download Static Binaries](https://git.gae.moe/grace/tpb/releases) + > Usage: `command | tpb` I was somewhat fed up with `pbcopy` eating stdout making it impossible to see From 805392ddcac77e09cfef0ba3e48a6d9f624a1712 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 17:48:16 -0600 Subject: [PATCH 05/14] tests(OSC52): Added base64 encoding test cases --- src/Clip/OSC52.zig | 89 +++++++++++++++++++++++++++++++++++----------- src/main.zig | 4 +++ 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/src/Clip/OSC52.zig b/src/Clip/OSC52.zig index 44d92bf..d95fca1 100644 --- a/src/Clip/OSC52.zig +++ b/src/Clip/OSC52.zig @@ -13,7 +13,7 @@ base64buffer: std.ArrayList(u8), leftover_bytes: [3]u8, leftover_count: u8, -const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); +const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, '='); pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { return .{ @@ -25,31 +25,34 @@ pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { }; } -pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { +pub fn free(self: *OSC52) void { + self.base64buffer.clearAndFree(self.alloc); +} + +pub fn writeCopyBuffer(self: *OSC52, buf: []const u8) anyerror!void { if (buf.len == 0) return; // Start of buffer after dealing with leftover bytes const buf_idx = 3 - self.leftover_count; - if (buf.len + self.leftover_count < 3) { - const len = buf.len + self.leftover_count; - @memcpy(self.leftover_bytes[self.leftover_count..len], buf[0..buf.len]); - self.leftover_count = @intCast(len); + // When we dont fill the leftover buffer, just copy data + if (buf_idx > buf.len) { + const end = self.leftover_count + buf.len; + @memcpy(self.leftover_bytes[self.leftover_count..end], buf[0..buf.len]); + self.leftover_count = @intCast(end); return; } - if (self.leftover_count != 0) { - @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); - try self.base64buffer.appendSlice(self.alloc, s); - } + @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); + var temp: [5]u8 = undefined; + var s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); + try self.base64buffer.appendSlice(self.alloc, s); const chunkable_length = buf.len - ((buf.len - buf_idx) % 3); - var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + var chunker = std.mem.window(u8, buf[buf_idx..chunkable_length], 3, 3); while (chunker.next()) |chunk| { - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, chunk); + // var temp: [5]u8 = undefined; + s = OSC52.base64encoder.encode(&temp, chunk); try self.base64buffer.appendSlice(self.alloc, s); } @@ -59,7 +62,14 @@ pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { } } -pub fn writePasteboard(self: *OSC52) !void { +pub fn encodeRemaining(self: *OSC52) anyerror!void { + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); + try self.base64buffer.appendSlice(self.alloc, s); +} + +pub fn writePasteboard(self: *OSC52) anyerror!void { + try self.encodeRemaining(); // https://ghostty.org/docs/vt/osc/52 const osc_header = &[_]u8{ @@ -76,14 +86,51 @@ pub fn writePasteboard(self: *OSC52) !void { 0x5c, // '\' }; - // Write leftover bytes - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); - try self.base64buffer.appendSlice(self.alloc, s); - // Write data to stdout const stdout = 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); } + +test "basic base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDE=", osc.base64buffer.items); +} + +test "multi part base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.writeCopyBuffer("part2"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0Mg==", osc.base64buffer.items); +} + +test "tiny buffers base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.writeCopyBuffer("part2"); + try osc.writeCopyBuffer("1"); + try osc.writeCopyBuffer("2"); + try osc.writeCopyBuffer("3"); + try osc.writeCopyBuffer("45"); + try osc.writeCopyBuffer("6"); + try osc.writeCopyBuffer("78"); + try osc.writeCopyBuffer("90fin"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0MjEyMzQ1Njc4OTBmaW4=", osc.base64buffer.items); +} + +test "big buffers base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1part1part1part1part1part1part1part1part1|"); + try osc.writeCopyBuffer("part2part2part2part2part2part2part2part2part2"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0MXBhcnQxcGFydDFwYXJ0MXBhcnQxcGFydDFwYXJ0MXBhcnQxfHBhcnQycGFydDJwYXJ0MnBhcnQycGFydDJwYXJ0MnBhcnQycGFydDJwYXJ0Mg==", osc.base64buffer.items); +} diff --git a/src/main.zig b/src/main.zig index 5c1a339..2d5bc56 100644 --- a/src/main.zig +++ b/src/main.zig @@ -38,3 +38,7 @@ pub fn main(init: std.process.Init) !void { std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); } + +test { + _ = @import("Clip.zig"); +} From e92e3603fd77a6812aa29f1b072435eece706948 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 17:48:16 -0600 Subject: [PATCH 06/14] tests(Clip): added explicit interface checking --- build.zig | 13 +++++++++++++ src/Clip.zig | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/build.zig b/build.zig index 4e2fbe0..242a70c 100644 --- a/build.zig +++ b/build.zig @@ -14,6 +14,19 @@ pub fn build(b: *std.Build) void { b.installArtifact(exe); + const tests = b.addTest(.{ + .name = "tpb", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const test_step = b.step("test", "Run tests"); + const run_tests = b.addRunArtifact(tests); + test_step.dependOn(&run_tests.step); + const run_step = b.step("run", "Run the app"); const run_cmd = b.addRunArtifact(exe); diff --git a/src/Clip.zig b/src/Clip.zig index 7d2f6db..8d264c8 100644 --- a/src/Clip.zig +++ b/src/Clip.zig @@ -1 +1,30 @@ pub const OSC52 = @import("Clip/OSC52.zig"); + +// 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 +// to use the `anytype` type and we are able to have an interface that is able +// to dispatch statically + +/// Check if a function with the given name and type exists. +/// `@compileError` if not +fn assertFn(comptime T: type, comptime name: []const u8, comptime Sig: type) void { + if (!@import("std").meta.hasFn(T, name)) + @compileError(@typeName(T) ++ " missing fn " ++ name); + if (@TypeOf(@field(T, name)) != Sig) + @compileError(@typeName(T) ++ "." ++ name ++ " must be " ++ @typeName(Sig)); +} + +test { + inline for (@typeInfo(@This()).@"struct".decls) |decl| { + // Only grab types of structs + const T = @field(@This(), decl.name); + if (@TypeOf(T) != type) continue; + if (@typeInfo(T) != .@"struct") continue; + + // ============================== + // === Interface Requirements === + // ============================== + assertFn(T, "writeCopyBuffer", fn (*T, []const u8) anyerror!void); + assertFn(T, "writePasteboard", fn (*T) anyerror!void); + } +} From eda3acce664c4b4f4a8d4a80a005f1515ed03356 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Thu, 25 Jun 2026 11:47:54 -0600 Subject: [PATCH 07/14] refactor: create new run function in main using anytype --- src/main.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index 2d5bc56..ea234e5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,11 +11,16 @@ pub fn main(init: std.process.Init) !void { std.process.exit(1); } - std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); - // 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(); + + run(io, clip); +} + +pub fn run(io: *std.Io, clip: anytype) !void { + std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); var buf: [4096]u8 = undefined; while (true) { From bcf298696b1a6b691cf15fc8023ac9c4a15bfe85 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 17 Jun 2026 09:47:42 -0600 Subject: [PATCH 08/14] init: create tpb --- .gitignore | 17 +++++++++++ LICENSE | 22 ++++++++++++++ README.md | 15 ++++++++++ build.zig | 27 +++++++++++++++++ build.zig.zon | 13 +++++++++ src/main.zig | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d3982c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# This file is for zig-specific build artifacts. If you have OS-specific or +# editor-specific files to ignore, such as *.swp or .DS_Store, put those in +# your global ~/.gitignore and put this in your ~/.gitconfig: +# +# [core] +# excludesfile = ~/.gitignore +# +# Cheers! +# -andrewrk + +.zig-cache/ +zig-out/ +/release/ +/debug/ +/build/ +/build-*/ +/docgen_tmp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b66f4c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright 2026 Grace Yoder + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8eeb08 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# TPB + +> Pronounced `tee pee bee` or `tee pasteboard` + +TPB is a super simple, small program to "tee" into my clipboard using OSC 52 +codes. + +> Usage: `command | tpb` + +I was somewhat fed up with `pbcopy` eating stdout making it impossible to see +the output of what I was doing without pasting it. While, yes, this could just +be a shell script, I decided to write in a systems language because its more fun +and I like the idea of it being super low latency. Originally, I was going to +write this in Rust but decided that Zig was a better fit for something like +this. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..4e2fbe0 --- /dev/null +++ b/build.zig @@ -0,0 +1,27 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const exe = b.addExecutable(.{ + .name = "tpb", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..304943f --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .tpb, + .version = "0.0.1", + .fingerprint = 0x203a601c46df0265, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + }, +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..cd716ab --- /dev/null +++ b/src/main.zig @@ -0,0 +1,80 @@ +const std = @import("std"); +const File = std.Io.File; + +pub fn main(init: std.process.Init) !void { + const io = init.io; + + // 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.process.exit(1); + } + + std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); + + // We want to avoid doing as many allocations as possible so having raw + // pages with it being larger should be totally fine + const alloc = std.heap.page_allocator; + + var buf: [4096]u8 = undefined; + var copy_index: u8 = 0; + + var encoded_buffer = try std.ArrayList(u8).initCapacity(alloc, std.heap.pageSize()); + var encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); + + while (true) { + // Read as much of stdin there is + const bytes = File.stdin().readStreaming(io, &.{buf[copy_index..]}) catch |err| { + if (err == File.ReadStreamingError.EndOfStream) { + break; + } else { + return err; + } + }; + const length = copy_index + bytes; + + // Passthrough to stdout + _ = try File.stdout().writeStreamingAll(io, buf[copy_index..length]); + + // Base64 encode + const chunkable_length = length - (length % 3); + var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + while (chunker.next()) |chunk| { + var temp: [5]u8 = undefined; + const s = encoder.encode(&temp, chunk); + try encoded_buffer.appendSlice(alloc, s); + } + + // Move left over bits to the start of the array + copy_index = @intCast(length - chunkable_length); + if (copy_index != 0) { + @memmove(buf[0..copy_index], buf[chunkable_length..length]); + } + } + // Encode last bytes + var temp: [5]u8 = undefined; + const s = encoder.encode(&temp, buf[0..copy_index]); + try encoded_buffer.appendSlice(alloc, s); + + // OSC 52 Copying — written as one contiguous buffer so the terminal + // receives the complete sequence in a single write. + // https://ghostty.org/docs/vt/osc/52 + const osc_header = &[_]u8{ + 0x1b, // ESC + 0x5d, // ']' + 0x35, 0x32, // Code 52 + 0x3b, // ';' + // 0x70, // 'p' Primary Clipboard + 0x63, // 'c' Standard Clipboard + 0x3b, // ';' + }; + const osc_footer = &[_]u8{ + 0x1b, // ESC + 0x5c, // '\' + }; + + _ = try File.stdout().writeStreamingAll(io, osc_header); + _ = try File.stdout().writeStreamingAll(io, encoded_buffer.items); + _ = try File.stdout().writeStreamingAll(io, osc_footer); + std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); +} From d7feea3f86358bea2fc3c2c15875706183530d58 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 17:48:16 -0600 Subject: [PATCH 09/14] refactor: split OSC52 off into different interface --- src/Clip.zig | 1 + src/Clip/OSC52.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 54 ++++------------------------ 3 files changed, 97 insertions(+), 47 deletions(-) create mode 100644 src/Clip.zig create mode 100644 src/Clip/OSC52.zig diff --git a/src/Clip.zig b/src/Clip.zig new file mode 100644 index 0000000..7d2f6db --- /dev/null +++ b/src/Clip.zig @@ -0,0 +1 @@ +pub const OSC52 = @import("Clip/OSC52.zig"); diff --git a/src/Clip/OSC52.zig b/src/Clip/OSC52.zig new file mode 100644 index 0000000..44d92bf --- /dev/null +++ b/src/Clip/OSC52.zig @@ -0,0 +1,89 @@ +/// OSC52 Clipboard backend +/// Fullfils `Clip` interface +pub const OSC52 = @This(); + +const std = @import("std"); +const Io = std.Io; + +alloc: std.mem.Allocator, +io: std.Io, + +base64buffer: std.ArrayList(u8), + +leftover_bytes: [3]u8, +leftover_count: u8, + +const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); + +pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { + return .{ + .alloc = alloc, + .io = io, + .base64buffer = try std.ArrayList(u8).initCapacity(alloc, buf_size), + .leftover_bytes = undefined, + .leftover_count = 0, + }; +} + +pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { + if (buf.len == 0) return; + + // Start of buffer after dealing with leftover bytes + const buf_idx = 3 - self.leftover_count; + + if (buf.len + self.leftover_count < 3) { + const len = buf.len + self.leftover_count; + @memcpy(self.leftover_bytes[self.leftover_count..len], buf[0..buf.len]); + self.leftover_count = @intCast(len); + return; + } + + if (self.leftover_count != 0) { + @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); + try self.base64buffer.appendSlice(self.alloc, s); + } + + const chunkable_length = buf.len - ((buf.len - buf_idx) % 3); + var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + while (chunker.next()) |chunk| { + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, chunk); + try self.base64buffer.appendSlice(self.alloc, s); + } + + self.leftover_count = @intCast(buf.len - chunkable_length); + if (self.leftover_count != 0) { + @memmove(self.leftover_bytes[0..self.leftover_count], buf[chunkable_length..]); + } +} + +pub fn writePasteboard(self: *OSC52) !void { + + // https://ghostty.org/docs/vt/osc/52 + const osc_header = &[_]u8{ + 0x1b, // ESC + 0x5d, // ']' + 0x35, 0x32, // Code 52 + 0x3b, // ';' + // 0x70, // 'p' Primary Clipboard + 0x63, // 'c' Standard Clipboard + 0x3b, // ';' + }; + const osc_footer = &[_]u8{ + 0x1b, // ESC + 0x5c, // '\' + }; + + // Write leftover bytes + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); + try self.base64buffer.appendSlice(self.alloc, s); + + // Write data to stdout + const stdout = 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 cd716ab..5c1a339 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const File = std.Io.File; +const Clip = @import("Clip.zig"); pub fn main(init: std.process.Init) !void { const io = init.io; @@ -14,67 +15,26 @@ 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 - const alloc = std.heap.page_allocator; + var clip = try Clip.OSC52.init(std.heap.page_allocator, init.io, std.heap.pageSize()); var buf: [4096]u8 = undefined; - var copy_index: u8 = 0; - - var encoded_buffer = try std.ArrayList(u8).initCapacity(alloc, std.heap.pageSize()); - var encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); - while (true) { // Read as much of stdin there is - const bytes = File.stdin().readStreaming(io, &.{buf[copy_index..]}) catch |err| { + const bytes = File.stdin().readStreaming(io, &.{&buf}) catch |err| { if (err == File.ReadStreamingError.EndOfStream) { break; } else { return err; } }; - const length = copy_index + bytes; - // Passthrough to stdout - _ = try File.stdout().writeStreamingAll(io, buf[copy_index..length]); + _ = try File.stdout().writeStreamingAll(io, buf[0..bytes]); - // Base64 encode - const chunkable_length = length - (length % 3); - var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); - while (chunker.next()) |chunk| { - var temp: [5]u8 = undefined; - const s = encoder.encode(&temp, chunk); - try encoded_buffer.appendSlice(alloc, s); - } - - // Move left over bits to the start of the array - copy_index = @intCast(length - chunkable_length); - if (copy_index != 0) { - @memmove(buf[0..copy_index], buf[chunkable_length..length]); - } + // Write to clip + try clip.writeCopyBuffer(buf[0..bytes]); } - // Encode last bytes - var temp: [5]u8 = undefined; - const s = encoder.encode(&temp, buf[0..copy_index]); - try encoded_buffer.appendSlice(alloc, s); - // OSC 52 Copying — written as one contiguous buffer so the terminal - // receives the complete sequence in a single write. - // https://ghostty.org/docs/vt/osc/52 - const osc_header = &[_]u8{ - 0x1b, // ESC - 0x5d, // ']' - 0x35, 0x32, // Code 52 - 0x3b, // ';' - // 0x70, // 'p' Primary Clipboard - 0x63, // 'c' Standard Clipboard - 0x3b, // ';' - }; - const osc_footer = &[_]u8{ - 0x1b, // ESC - 0x5c, // '\' - }; + try clip.writePasteboard(); - _ = try File.stdout().writeStreamingAll(io, osc_header); - _ = try File.stdout().writeStreamingAll(io, encoded_buffer.items); - _ = try File.stdout().writeStreamingAll(io, osc_footer); std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); } From 62f89fe05bd3e805bf72728a551fc49dbf88cae2 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 18:15:37 -0600 Subject: [PATCH 10/14] feat: crow ci --- .crow/build.yml | 60 +++++++++++++++++++++++++++++++++++++++++++++++ .crow/release.yml | 23 ++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 .crow/build.yml create mode 100644 .crow/release.yml diff --git a/.crow/build.yml b/.crow/build.yml new file mode 100644 index 0000000..e6945f6 --- /dev/null +++ b/.crow/build.yml @@ -0,0 +1,60 @@ +when: + - event: tag + +clone: + git: + image: codefloe.com/crow-plugins/clone:1.1.0 + settings: + tags: true + +steps: + - name: build-linux-x86_64 + image: alpine:3.20 + 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 + + - name: build-linux-aarch64 + image: alpine:3.20 + 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 + + - name: build-macos-x86_64 + image: alpine:3.20 + 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 + + - name: build-macos-aarch64 + image: alpine:3.20 + 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 + + + - name: release + image: woodpeckerci/plugin-release + depends_on: [build-linux-x86_64, build-linux-aarch64, build-macos-x86_64, build-macos-aarch64] + settings: + api_key: + from_secret: forgejo_token + files: + - tpb-linux-x86_64 + - tpb-linux-aarch64 + - tpb-macos-x86_64 + - tpb-macos-aarch64 + checksum: sha256 + file_exists: overwrite diff --git a/.crow/release.yml b/.crow/release.yml new file mode 100644 index 0000000..3770f56 --- /dev/null +++ b/.crow/release.yml @@ -0,0 +1,23 @@ +# This file is a bit of a hack in order to run the real release CI +when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + +clone: + git: + image: codefloe.com/crow-plugins/clone:1.1.0 + settings: + tags: true + + +steps: + - name: retag-latest + image: alpine:3.20 + environment: + FORGEJO_TOKEN: + from_secret: forgejo_token + commands: + - apk add --no-cache git + - git remote set-url origin "https://oauth2:$${FORGEJO_TOKEN}@$${CI_FORGE_URL#https://}/$${CI_REPO}.git" + - git tag -f latest + - git push -f origin latest From bf46515ca560fb24f581a82fa329e0f40d1bd4f2 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 19:15:20 -0600 Subject: [PATCH 11/14] docs: add releases link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e8eeb08..7893341 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ TPB is a super simple, small program to "tee" into my clipboard using OSC 52 codes. +[Download Static Binaries](https://git.gae.moe/grace/tpb/releases) + > Usage: `command | tpb` I was somewhat fed up with `pbcopy` eating stdout making it impossible to see From 47f2491ae0688637d82900dfe8d92b8b4e774e8e Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 17:48:16 -0600 Subject: [PATCH 12/14] tests(OSC52): Added base64 encoding test cases --- src/Clip/OSC52.zig | 89 +++++++++++++++++++++++++++++++++++----------- src/main.zig | 4 +++ 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/src/Clip/OSC52.zig b/src/Clip/OSC52.zig index 44d92bf..d95fca1 100644 --- a/src/Clip/OSC52.zig +++ b/src/Clip/OSC52.zig @@ -13,7 +13,7 @@ base64buffer: std.ArrayList(u8), leftover_bytes: [3]u8, leftover_count: u8, -const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, null); +const base64encoder = std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, '='); pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { return .{ @@ -25,31 +25,34 @@ pub fn init(alloc: std.mem.Allocator, io: Io, buf_size: usize) !OSC52 { }; } -pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { +pub fn free(self: *OSC52) void { + self.base64buffer.clearAndFree(self.alloc); +} + +pub fn writeCopyBuffer(self: *OSC52, buf: []const u8) anyerror!void { if (buf.len == 0) return; // Start of buffer after dealing with leftover bytes const buf_idx = 3 - self.leftover_count; - if (buf.len + self.leftover_count < 3) { - const len = buf.len + self.leftover_count; - @memcpy(self.leftover_bytes[self.leftover_count..len], buf[0..buf.len]); - self.leftover_count = @intCast(len); + // When we dont fill the leftover buffer, just copy data + if (buf_idx > buf.len) { + const end = self.leftover_count + buf.len; + @memcpy(self.leftover_bytes[self.leftover_count..end], buf[0..buf.len]); + self.leftover_count = @intCast(end); return; } - if (self.leftover_count != 0) { - @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); - try self.base64buffer.appendSlice(self.alloc, s); - } + @memcpy(self.leftover_bytes[self.leftover_count..3], buf[0..buf_idx]); + var temp: [5]u8 = undefined; + var s = OSC52.base64encoder.encode(&temp, &self.leftover_bytes); + try self.base64buffer.appendSlice(self.alloc, s); const chunkable_length = buf.len - ((buf.len - buf_idx) % 3); - var chunker = std.mem.window(u8, buf[0..chunkable_length], 3, 3); + var chunker = std.mem.window(u8, buf[buf_idx..chunkable_length], 3, 3); while (chunker.next()) |chunk| { - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, chunk); + // var temp: [5]u8 = undefined; + s = OSC52.base64encoder.encode(&temp, chunk); try self.base64buffer.appendSlice(self.alloc, s); } @@ -59,7 +62,14 @@ pub fn writeCopyBuffer(self: *OSC52, buf: []u8) !void { } } -pub fn writePasteboard(self: *OSC52) !void { +pub fn encodeRemaining(self: *OSC52) anyerror!void { + var temp: [5]u8 = undefined; + const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); + try self.base64buffer.appendSlice(self.alloc, s); +} + +pub fn writePasteboard(self: *OSC52) anyerror!void { + try self.encodeRemaining(); // https://ghostty.org/docs/vt/osc/52 const osc_header = &[_]u8{ @@ -76,14 +86,51 @@ pub fn writePasteboard(self: *OSC52) !void { 0x5c, // '\' }; - // Write leftover bytes - var temp: [5]u8 = undefined; - const s = OSC52.base64encoder.encode(&temp, self.leftover_bytes[0..self.leftover_count]); - try self.base64buffer.appendSlice(self.alloc, s); - // Write data to stdout const stdout = 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); } + +test "basic base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDE=", osc.base64buffer.items); +} + +test "multi part base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.writeCopyBuffer("part2"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0Mg==", osc.base64buffer.items); +} + +test "tiny buffers base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1"); + try osc.writeCopyBuffer("part2"); + try osc.writeCopyBuffer("1"); + try osc.writeCopyBuffer("2"); + try osc.writeCopyBuffer("3"); + try osc.writeCopyBuffer("45"); + try osc.writeCopyBuffer("6"); + try osc.writeCopyBuffer("78"); + try osc.writeCopyBuffer("90fin"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0MjEyMzQ1Njc4OTBmaW4=", osc.base64buffer.items); +} + +test "big buffers base64 encode" { + var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024); + defer osc.free(); + try osc.writeCopyBuffer("part1part1part1part1part1part1part1part1part1|"); + try osc.writeCopyBuffer("part2part2part2part2part2part2part2part2part2"); + try osc.encodeRemaining(); + try std.testing.expectEqualSlices(u8, "cGFydDFwYXJ0MXBhcnQxcGFydDFwYXJ0MXBhcnQxcGFydDFwYXJ0MXBhcnQxfHBhcnQycGFydDJwYXJ0MnBhcnQycGFydDJwYXJ0MnBhcnQycGFydDJwYXJ0Mg==", osc.base64buffer.items); +} diff --git a/src/main.zig b/src/main.zig index 5c1a339..2d5bc56 100644 --- a/src/main.zig +++ b/src/main.zig @@ -38,3 +38,7 @@ pub fn main(init: std.process.Init) !void { std.debug.print("\x1b[0;32mCopied To Pasteboard!\x1b[0m\n", .{}); } + +test { + _ = @import("Clip.zig"); +} From ecdcf0c0b89e478e8bf65cbd76655411c341c156 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Wed, 24 Jun 2026 17:48:16 -0600 Subject: [PATCH 13/14] tests(Clip): added explicit interface checking --- build.zig | 13 +++++++++++++ src/Clip.zig | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/build.zig b/build.zig index 4e2fbe0..242a70c 100644 --- a/build.zig +++ b/build.zig @@ -14,6 +14,19 @@ pub fn build(b: *std.Build) void { b.installArtifact(exe); + const tests = b.addTest(.{ + .name = "tpb", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const test_step = b.step("test", "Run tests"); + const run_tests = b.addRunArtifact(tests); + test_step.dependOn(&run_tests.step); + const run_step = b.step("run", "Run the app"); const run_cmd = b.addRunArtifact(exe); diff --git a/src/Clip.zig b/src/Clip.zig index 7d2f6db..8d264c8 100644 --- a/src/Clip.zig +++ b/src/Clip.zig @@ -1 +1,30 @@ pub const OSC52 = @import("Clip/OSC52.zig"); + +// 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 +// to use the `anytype` type and we are able to have an interface that is able +// to dispatch statically + +/// Check if a function with the given name and type exists. +/// `@compileError` if not +fn assertFn(comptime T: type, comptime name: []const u8, comptime Sig: type) void { + if (!@import("std").meta.hasFn(T, name)) + @compileError(@typeName(T) ++ " missing fn " ++ name); + if (@TypeOf(@field(T, name)) != Sig) + @compileError(@typeName(T) ++ "." ++ name ++ " must be " ++ @typeName(Sig)); +} + +test { + inline for (@typeInfo(@This()).@"struct".decls) |decl| { + // Only grab types of structs + const T = @field(@This(), decl.name); + if (@TypeOf(T) != type) continue; + if (@typeInfo(T) != .@"struct") continue; + + // ============================== + // === Interface Requirements === + // ============================== + assertFn(T, "writeCopyBuffer", fn (*T, []const u8) anyerror!void); + assertFn(T, "writePasteboard", fn (*T) anyerror!void); + } +} From a553c9b0601d125aaf7893b216db5c72307161c6 Mon Sep 17 00:00:00 2001 From: Grace Yoder Date: Thu, 25 Jun 2026 11:47:54 -0600 Subject: [PATCH 14/14] refactor: create new run function in main using anytype --- src/main.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index 2d5bc56..ea234e5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,11 +11,16 @@ pub fn main(init: std.process.Init) !void { std.process.exit(1); } - std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); - // 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(); + + run(io, clip); +} + +pub fn run(io: *std.Io, clip: anytype) !void { + std.debug.print("\x1b[0;32mCopying the following to Pasteboard\x1b[0m\n", .{}); var buf: [4096]u8 = undefined; while (true) {