diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index ddc768c34..7ef850bfe 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -37,6 +37,7 @@ const oscpkg = @import("../osc.zig"); const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); +const response = @import("clipboard_response.zig"); pub const OSC = protocol.OSC; pub const Operation = protocol.Operation; @@ -50,6 +51,12 @@ pub const max_pw_len = command.max_pw_len; pub const max_mime_len = command.max_mime_len; pub const max_name_len = command.max_name_len; +pub const Response = response.Response; +pub const ReadSuccess = response.ReadSuccess; +pub const read_chunk_size = response.read_chunk_size; +pub const max_read_mimes = response.max_read_mimes; +pub const targets_mime = response.targets_mime; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/kitty/clipboard_response.zig b/src/terminal/kitty/clipboard_response.zig new file mode 100644 index 000000000..c7f01c165 --- /dev/null +++ b/src/terminal/kitty/clipboard_response.zig @@ -0,0 +1,385 @@ +//! Kitty clipboard protocol (OSC 5522) response encoding. + +const std = @import("std"); +const clipboard = @import("../clipboard.zig"); +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const b64 = std.base64.standard.Encoder; +const Content = clipboard.Content; +const Operation = protocol.Operation; +const Status = protocol.Status; +const Terminator = oscpkg.Terminator; + +/// Maximum raw (pre-base64) bytes per DATA packet in read responses. +/// This is specified by the protocol. +pub const read_chunk_size = 4096; + +/// Maximum requested MIME types served by a single read request. +/// Requests beyond this simply see no DATA packets for the extras, +/// which is how the protocol communicates an unavailable type anyway. +pub const max_read_mimes = 4; + +/// The special MIME type that requests the list of available types. +pub const targets_mime = "."; + +/// A single response packet. +pub const Response = struct { + op: Operation, + status: Status, + primary: bool = false, + id: []const u8 = "", + mime: ?[]const u8 = null, + pw: ?[]const u8 = null, + /// The raw payload; the encoder base64-encodes it. An empty payload + /// emits no payload section at all (no ';'). + payload: []const u8 = "", + terminator: Terminator = .st, + + /// Encode the response. Errors may result in partially written data + /// so it is up to callers to buffer it if they need to. + pub fn encode( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try self.encodeMetadata(writer); + if (self.payload.len > 0) { + try writer.writeAll(";"); + try b64.encodeWriter(writer, self.payload); + } + try writer.writeAll(self.terminator.string()); + } + + /// Encode the escape prefix and metadata section only: everything + /// up to (and not including) the payload section and terminator. + fn encodeMetadata( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // The exact order of fields here matches what Kitty does. + try writer.print("\x1b]5522;type={t}:status={t}", .{ self.op, self.status }); + if (self.primary) try writer.writeAll(":loc=primary"); + if (self.id.len > 0) try writer.print(":id={s}", .{self.id}); + if (self.mime) |mime| { + try writer.writeAll(":mime="); + try b64.encodeWriter(writer, mime); + } + if (self.pw) |pw| { + try writer.writeAll(":pw="); + try b64.encodeWriter(writer, pw); + } + } +}; + +/// Encode a full successful read response: the OK packet, the targets +/// listing if requested, DATA chunks for each served representation, +/// and the final DONE packet. This is also the shape of an unsolicited +/// paste event (list=true, pw set to the one-time password). +pub const ReadSuccess = struct { + primary: bool = false, + id: []const u8 = "", + + /// One-time password echoed in every packet. Only used for + /// terminal-initiated paste events. + pw: ?[]const u8 = null, + + /// True when the targets ('.') listing was requested. + list: bool = false, + + /// The MIME types available on the clipboard, reported by the + /// targets listing. Kitty reports these space-separated in one + /// packet with a trailing newline when non-empty. + available: []const []const u8 = &.{}, + + /// The representations to serve, in request order. Each entry's + /// data is chunked into DATA packets under its own MIME type. An + /// entry with empty data produces no packets, which is how the + /// protocol communicates an unavailable type. + contents: []const Content = &.{}, + + terminator: Terminator = .st, + + pub fn encode( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // Initial read response + try (Response{ + .op = .read, + .status = .OK, + .primary = self.primary, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + + // Listing of mimes if requested + if (self.list) try self.encodeListing(writer); + + // Encoding of each mime-type + content + for (self.contents) |content| { + var i: usize = 0; + while (i < content.data.len) { + const n = @min(content.data.len - i, read_chunk_size); + try (Response{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = content.mime, + .pw = self.pw, + .payload = content.data[i..][0..n], + .terminator = self.terminator, + }).encode(writer); + i += n; + } + } + + // Trailing done. + try (Response{ + .op = .read, + .status = .DONE, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + } + + /// Encode the targets ('.') listing packet. The listing gets a + /// trailing newline when non-empty. + fn encodeListing( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const listing: Response = .{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = targets_mime, + .pw = self.pw, + .terminator = self.terminator, + }; + try listing.encodeMetadata(writer); + if (self.available.len > 0) { + try writer.writeAll(";"); + + // Join the types into one chunk before encoding. The + // listing is a DATA packet, so it shares the pre-encoding + // chunk size bound of any other data packet; types that + // don't fit are dropped (a listing that large doesn't + // happen in practice). + var raw: [read_chunk_size]u8 = undefined; + var fixed: std.Io.Writer = .fixed(&raw); + for (self.available, 0..) |mime, i| { + const sep: usize = if (i > 0) 1 else 0; + if (fixed.end + sep + mime.len + "\n".len > raw.len) break; + if (i > 0) fixed.writeAll(" ") catch unreachable; + fixed.writeAll(mime) catch unreachable; + } + fixed.writeAll("\n") catch unreachable; + try b64.encodeWriter(writer, fixed.buffered()); + } + try writer.writeAll(self.terminator.string()); + } +}; + +test "response: basic status packet" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ .op = .write, .status = .DONE }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "response: id echo and terminator" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .write, + .status = .EPERM, + .id = "42", + .terminator = .bel, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=EPERM:id=42\x07", + writer.buffered(), + ); +} + +test "response: key order type,status,loc,id,mime,pw and payload" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .read, + .status = .DATA, + .primary = true, + .id = "x", + .mime = "text/plain", + .pw = "otp", + .payload = "Ghostty", + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=DATA:loc=primary:id=x" ++ + ":mime=dGV4dC9wbGFpbg==:pw=b3Rw;R2hvc3R0eQ==\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty request is OK then DONE" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .id = "7" }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:id=7\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:id=7\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing with text" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true, .available = &.{"text/plain"} }).encode(&writer); + // "." => "Lg==", "text/plain\n" => "dGV4dC9wbGFpbgo=" + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing joins multiple types" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .available = &.{ "text/plain", "image/png" }, + }).encode(&writer); + // Payload is base64 of "text/plain image/png\n". + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty targets listing packet still sent" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: data chunks under requested mime" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "Ghostty" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: each representation carries its own data" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{ + .{ .mime = "text/plain", .data = "hello" }, + .{ .mime = "image/png", .data = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" }, + }, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=aW1hZ2UvcG5n;iVBORw0KGgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: chunking at read_chunk_size" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = "z" ** (read_chunk_size + 1); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = data }}, + }).encode(&aw.writer); + + // OK + 2 DATA packets + DONE = 4 packets. + const count = std.mem.count(u8, aw.written(), "\x1b]5522;"); + try testing.expectEqual(@as(usize, 4), count); + + // The first chunk is exactly read_chunk_size bytes, base64 encoded + // with padding. + const Encoder = std.base64.standard.Encoder; + var chunk_buf: [Encoder.calcSize(read_chunk_size)]u8 = undefined; + const first = Encoder.encode(&chunk_buf, data[0..read_chunk_size]); + try testing.expect(std.mem.indexOf(u8, aw.written(), first) != null); +} + +test "read success: no data packets for empty clipboard" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: paste event carries pw in every packet" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .pw = "otp", + .available = &.{"text/plain"}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +}