mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal: add kitty clipboard protocol (OSC 5522) response encoding
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
385
src/terminal/kitty/clipboard_response.zig
Normal file
385
src/terminal/kitty/clipboard_response.zig
Normal file
@@ -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(),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user