mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
libghostty: implement Kitty clipboard protocol reads via clipboard_read effect
This commit is contained in:
@@ -584,8 +584,9 @@ typedef enum {
|
||||
* duration of the reply call and may be freed as soon as it returns.
|
||||
*
|
||||
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
|
||||
* program with an empty clipboard; the other fields are ignored in that
|
||||
* case. On success, `contents` should carry one representation per
|
||||
* program with an empty clipboard (OSC 52) or the matching protocol status
|
||||
* (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in
|
||||
* that case. On success, `contents` should carry one representation per
|
||||
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
|
||||
* has; unrequested representations are ignored. Protocols that carry a
|
||||
* single text value (OSC 52) use the first entry with a text MIME type
|
||||
@@ -649,7 +650,7 @@ typedef void (*GhosttyClipboardReadReplyFn)(
|
||||
* GhosttyClipboardReadReply. This must happen before the callback returns;
|
||||
* the request is invalid afterwards. Calling `reply` more than once is
|
||||
* ignored. Returning without replying answers the program with an empty
|
||||
* clipboard.
|
||||
* clipboard (OSC 52) or EPERM (OSC 5522).
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
@@ -707,15 +708,22 @@ struct GhosttyClipboardRead {
|
||||
* Callback function type for clipboard_read.
|
||||
*
|
||||
* Called synchronously when the running program requests clipboard contents
|
||||
* via OSC 52 with a "?" payload. Answering lets the program read the user's
|
||||
* clipboard, so the embedder is expected to mediate consent. Because the
|
||||
* read is synchronous, an embedder that needs to ask the user must block
|
||||
* (for example by running a modal prompt) until it has an answer; the VT
|
||||
* stream waits until the callback returns.
|
||||
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read.
|
||||
* Answering lets the program read the user's clipboard, so the embedder is
|
||||
* expected to mediate consent. Because the read is synchronous, an embedder
|
||||
* that needs to ask the user must block (for example by running a modal
|
||||
* prompt) until it has an answer; the VT stream waits until the callback
|
||||
* returns.
|
||||
*
|
||||
* Answer by calling `read->reply(read, &reply)` before returning. See
|
||||
* GhosttyClipboardRead for the full contract.
|
||||
*
|
||||
* OSC 5522 requests carry the program's MIME list, name, and password grant
|
||||
* state; a reply that sets `remember` records a session grant so later
|
||||
* requests with the same password arrive with `granted` set. Kitty itself
|
||||
* serves a request for only the targets listing (`list` with no `mimes`)
|
||||
* without prompting.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
* @param read Borrowed clipboard read request
|
||||
@@ -1424,9 +1432,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
|
||||
/**
|
||||
* Callback invoked when the running program requests clipboard contents
|
||||
* via OSC 52 with a "?" payload. The read is synchronous and must be
|
||||
* answered before the callback returns. Set to NULL to ignore clipboard
|
||||
* read requests (the default).
|
||||
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The
|
||||
* read is synchronous and must be answered before the callback returns.
|
||||
* Set to NULL (the default) to ignore OSC 52 read requests and refuse
|
||||
* OSC 5522 reads with EPERM.
|
||||
*
|
||||
* Input type: GhosttyTerminalClipboardReadFn
|
||||
*/
|
||||
|
||||
@@ -241,10 +241,11 @@ pub const ModeConfig = extern struct {
|
||||
/// C callback state for terminal effects. Most trampolines are always
|
||||
/// installed on the stream handler; they check these fields and no-op when
|
||||
/// the corresponding callback is null. The unknown-sequence and
|
||||
/// clipboard-write trampolines are installed dynamically to preserve
|
||||
/// their null fast paths (for clipboard_write, a null Zig-level effect
|
||||
/// makes Kitty clipboard writes fail up front instead of spooling a
|
||||
/// transaction that can never commit).
|
||||
/// clipboard trampolines are installed dynamically to preserve their
|
||||
/// null fast paths (for clipboard_write, a null Zig-level effect makes
|
||||
/// Kitty clipboard writes fail up front instead of spooling a
|
||||
/// transaction that can never commit; for clipboard_read it keeps
|
||||
/// reads denied).
|
||||
const Effects = struct {
|
||||
userdata: ?*anyopaque = null,
|
||||
write_pty: ?WritePtyFn = null,
|
||||
@@ -4845,7 +4846,7 @@ test "kitty clipboard write via C effects" {
|
||||
S.responses[0..S.responses_len],
|
||||
);
|
||||
|
||||
// Reads are always denied.
|
||||
// Without a read callback reads are denied.
|
||||
S.responses_len = 0;
|
||||
const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\";
|
||||
vt_write(t, read, read.len);
|
||||
@@ -4855,6 +4856,70 @@ test "kitty clipboard write via C effects" {
|
||||
S.responses[0..S.responses_len],
|
||||
);
|
||||
|
||||
// With a read callback the request is served through it.
|
||||
const R = struct {
|
||||
var count: usize = 0;
|
||||
var last_mimes_len: usize = 0;
|
||||
var last_mime_is_text: bool = false;
|
||||
var last_list: bool = true;
|
||||
var last_name_len: usize = 0;
|
||||
var last_granted: bool = true;
|
||||
var last_can_remember: bool = true;
|
||||
|
||||
fn clipboardRead(
|
||||
_: Terminal,
|
||||
_: ?*anyopaque,
|
||||
request: *const ClipboardRead,
|
||||
) callconv(lib.calling_conv) void {
|
||||
count += 1;
|
||||
last_mimes_len = request.mimes_len;
|
||||
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
|
||||
u8,
|
||||
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
|
||||
"text/plain",
|
||||
);
|
||||
last_list = request.list;
|
||||
last_name_len = request.name.len;
|
||||
last_granted = request.granted;
|
||||
last_can_remember = request.can_remember;
|
||||
|
||||
const mime: []const u8 = "text/plain";
|
||||
const data: []const u8 = "hello";
|
||||
const contents = [_]ClipboardContent{.{
|
||||
.mime = .init(mime),
|
||||
.data = .init(data),
|
||||
}};
|
||||
request.reply(request, &.{
|
||||
.size = @sizeOf(ClipboardReadReply),
|
||||
.result = .success,
|
||||
.contents = &contents,
|
||||
.contents_len = contents.len,
|
||||
.available = null,
|
||||
.available_len = 0,
|
||||
.remember = false,
|
||||
});
|
||||
}
|
||||
};
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead)));
|
||||
S.responses_len = 0;
|
||||
// name="app" without a password: forwarded for prompts, not
|
||||
// rememberable.
|
||||
const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\";
|
||||
vt_write(t, read2, read2.len);
|
||||
try testing.expectEqual(@as(usize, 1), R.count);
|
||||
try testing.expectEqual(@as(usize, 1), R.last_mimes_len);
|
||||
try testing.expect(R.last_mime_is_text);
|
||||
try testing.expect(!R.last_list);
|
||||
try testing.expectEqual(@as(usize, 3), R.last_name_len);
|
||||
try testing.expect(!R.last_granted);
|
||||
try testing.expect(!R.last_can_remember);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DONE:id=r2\x1B\\",
|
||||
S.responses[0..S.responses_len],
|
||||
);
|
||||
|
||||
// Without a clipboard callback the transaction fails up front.
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
|
||||
S.responses_len = 0;
|
||||
|
||||
@@ -28,8 +28,8 @@ pub const max_pw_len = 128;
|
||||
/// types are tiny; anything longer drops the packet.
|
||||
pub const max_mime_len = 256;
|
||||
|
||||
/// Maximum decoded name length we bother validating. Longer names are
|
||||
/// treated as present without validation; only their presence matters.
|
||||
/// Maximum decoded name length. Kitty has no limit but names are shown
|
||||
/// in permission prompts so anything longer drops the packet.
|
||||
pub const max_name_len = 256;
|
||||
|
||||
/// The decoded, validated metadata of one OSC 5522 sequence.
|
||||
@@ -62,9 +62,10 @@ pub const Metadata = struct {
|
||||
/// treat the request as though it had no password."
|
||||
pw: []const u8 = "",
|
||||
|
||||
/// True if a non-empty (valid) name was given. We don't retain the
|
||||
/// name contents; it exists to opt into password grants.
|
||||
has_name: bool = false,
|
||||
/// Decoded human friendly name of the requesting program, shown in
|
||||
/// permission prompts. Empty means absent. Its presence opts into
|
||||
/// password grants.
|
||||
name: []const u8 = "",
|
||||
|
||||
/// Parse the metadata field. The raw value is expected to be exactly
|
||||
/// the metadata (prefix and payload and separators stripped out).
|
||||
@@ -124,21 +125,13 @@ pub const Metadata = struct {
|
||||
error.Invalid => return null,
|
||||
};
|
||||
} else if (std.mem.eql(u8, key, "name")) {
|
||||
// We only need to know whether a (non-empty) name was
|
||||
// given; the contents are decoded for validation only.
|
||||
result.has_name = has_name: {
|
||||
const name = decodeValue(
|
||||
alloc,
|
||||
value,
|
||||
max_name_len,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// Over-long names are accepted as present but
|
||||
// not validated further.
|
||||
error.Overflow => break :has_name true,
|
||||
error.Invalid => return null,
|
||||
};
|
||||
break :has_name name.len > 0;
|
||||
result.name = decodeValue(
|
||||
alloc,
|
||||
value,
|
||||
max_name_len,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Overflow, error.Invalid => return null,
|
||||
};
|
||||
}
|
||||
// Unknown keys are ignored.
|
||||
@@ -351,7 +344,21 @@ test "metadata: pw and name" {
|
||||
// pw="secret", name="app"
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?;
|
||||
try testing.expectEqualStrings("secret", meta.pw);
|
||||
try testing.expect(meta.has_name);
|
||||
try testing.expectEqualStrings("app", meta.name);
|
||||
}
|
||||
|
||||
test "metadata: over-long name dropped" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
const long = "n" ** (max_name_len + 1);
|
||||
var buf: [Encoder.calcSize(long.len)]u8 = undefined;
|
||||
const raw = try std.mem.concat(arena.allocator(), u8, &.{
|
||||
"type=read:name=",
|
||||
Encoder.encode(&buf, long),
|
||||
});
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), raw)) == null);
|
||||
}
|
||||
|
||||
test "metadata: empty name" {
|
||||
@@ -359,7 +366,7 @@ test "metadata: empty name" {
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?;
|
||||
try testing.expect(!meta.has_name);
|
||||
try testing.expectEqual(@as(usize, 0), meta.name.len);
|
||||
}
|
||||
|
||||
test "payload: mime iterator" {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub const WriteState = struct {
|
||||
loc: clipboard.Location,
|
||||
id: []const u8,
|
||||
pw: []const u8,
|
||||
has_name: bool,
|
||||
name: []const u8,
|
||||
spool: std.ArrayListUnmanaged(u8) = .empty,
|
||||
entries: std.ArrayListUnmanaged(Entry) = .empty,
|
||||
aliases: std.ArrayListUnmanaged(Alias) = .empty,
|
||||
@@ -68,12 +68,13 @@ pub const WriteState = struct {
|
||||
errdefer arena.deinit();
|
||||
const id = try arena.allocator().dupe(u8, meta.id);
|
||||
const pw = try arena.allocator().dupe(u8, meta.pw);
|
||||
const name = try arena.allocator().dupe(u8, meta.name);
|
||||
return .{
|
||||
.arena = arena,
|
||||
.loc = meta.loc,
|
||||
.id = id,
|
||||
.pw = pw,
|
||||
.has_name = meta.has_name,
|
||||
.name = name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,7 +221,7 @@ pub const WriteState = struct {
|
||||
loc: clipboard.Location,
|
||||
id: []const u8,
|
||||
pw: []const u8,
|
||||
has_name: bool,
|
||||
name: []const u8,
|
||||
truncated: bool,
|
||||
contents: []const Content,
|
||||
|
||||
@@ -289,7 +290,7 @@ pub const WriteState = struct {
|
||||
.loc = self.loc,
|
||||
.id = self.id,
|
||||
.pw = self.pw,
|
||||
.has_name = self.has_name,
|
||||
.name = self.name,
|
||||
.truncated = self.truncated,
|
||||
.contents = try contents.toOwnedSlice(alloc),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,12 @@ pub const Handler = struct {
|
||||
/// Heap-allocated since transactions are rare and short-lived.
|
||||
kitty_clipboard_write: ?*kitty_clipboard.WriteState = null,
|
||||
|
||||
/// Kitty clipboard protocol (OSC 5522) session password grants,
|
||||
/// recorded when a clipboard_read reply asks to remember the user's
|
||||
/// decision. Later requests carrying a granted password are forwarded
|
||||
/// with `granted` set so the embedder can skip its prompt.
|
||||
kitty_clipboard_grants: kitty_clipboard.Grants = .{},
|
||||
|
||||
/// Called for sequence identifiers not supported by this library.
|
||||
/// Currently, only APC is reported. Content is borrowed and only valid
|
||||
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
|
||||
@@ -165,16 +171,24 @@ pub const Handler = struct {
|
||||
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
|
||||
|
||||
/// Called when the running program requests clipboard contents
|
||||
/// (OSC 52 with a "?" payload). Answering one lets the program
|
||||
/// read the user's clipboard, so the embedder is expected to
|
||||
/// mediate consent.
|
||||
/// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522)
|
||||
/// read). Answering one lets the program read the user's
|
||||
/// clipboard, so the embedder is expected to mediate consent.
|
||||
///
|
||||
/// Reads are synchronous: the callback must answer through
|
||||
/// `read.reply` before it returns, so an embedder that needs to
|
||||
/// ask the user must block (e.g. run a modal prompt) while the
|
||||
/// stream waits. Returning without a reply, or replying denied or
|
||||
/// unsupported, answers the program with an empty clipboard so it
|
||||
/// doesn't hang. If this is null, read requests are ignored.
|
||||
/// stream waits. Returning without a reply, or replying with any
|
||||
/// failure, answers the program with an empty clipboard (OSC 52)
|
||||
/// or the matching protocol status (OSC 5522) so it doesn't hang.
|
||||
/// If this is null, OSC 52 reads are ignored and OSC 5522 reads
|
||||
/// are refused with EPERM.
|
||||
///
|
||||
/// OSC 5522 requests carry the program's MIME list, name, and
|
||||
/// password grant state; a reply that sets `remember` records a
|
||||
/// session grant so later requests with the same password arrive
|
||||
/// with `granted` set. Kitty itself serves a request for only the
|
||||
/// targets listing (`list` with no `mimes`) without prompting.
|
||||
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
|
||||
|
||||
/// Called in response to an XTVERSION query. Returns the version
|
||||
@@ -223,6 +237,7 @@ pub const Handler = struct {
|
||||
|
||||
pub fn deinit(self: *Handler) void {
|
||||
self.kittyClipboardAbort();
|
||||
self.kitty_clipboard_grants.deinit(self.terminal.gpa());
|
||||
self.apc_handler.deinit();
|
||||
self.dcs_handler.deinit();
|
||||
}
|
||||
@@ -391,7 +406,7 @@ pub const Handler = struct {
|
||||
log.warn("error reporting Kitty colors err={}", .{err});
|
||||
},
|
||||
.kitty_clipboard => self.kittyClipboard(value) catch |err| {
|
||||
// Clipboard writes are external effects, not terminal
|
||||
// Clipboard operations are external effects, not terminal
|
||||
// state; a failed transaction was already answered.
|
||||
log.warn("error handling kitty clipboard err={}", .{err});
|
||||
},
|
||||
@@ -702,9 +717,8 @@ pub const Handler = struct {
|
||||
payload: []const u8,
|
||||
terminator: osc.Terminator,
|
||||
) error{OutOfMemory}!void {
|
||||
// The payload (the requested MIME list) must still decode even
|
||||
// though we never serve it: kitty drops a read request with an
|
||||
// undecodable payload without any response.
|
||||
// The payload is the requested MIME list. Kitty drops a read
|
||||
// request with an undecodable payload without any response.
|
||||
const alloc = self.terminal.gpa();
|
||||
const decoded = kitty_clipboard.Payload.init(
|
||||
alloc,
|
||||
@@ -713,17 +727,178 @@ pub const Handler = struct {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Invalid => return,
|
||||
};
|
||||
decoded.deinit(alloc);
|
||||
defer decoded.deinit(alloc);
|
||||
|
||||
// For now, EPERM always
|
||||
self.kittyClipboardRespond(&.{
|
||||
.op = .read,
|
||||
.status = .EPERM,
|
||||
// Without a clipboard_read effect nothing can serve the read.
|
||||
// EPERM is the protocol's denial so clients degrade gracefully.
|
||||
const func = self.effects.clipboard_read orelse {
|
||||
self.kittyClipboardRespond(&.{
|
||||
.op = .read,
|
||||
.status = .EPERM,
|
||||
.id = meta.id,
|
||||
.terminator = terminator,
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
// The targets type ('.') asks for the listing of available
|
||||
// types rather than data. Requested types beyond the cap are
|
||||
// dropped and simply never served, which is how the protocol
|
||||
// reports an unavailable type anyway.
|
||||
var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
|
||||
const mimes, const list = mimes: {
|
||||
var targets = false;
|
||||
var len: usize = 0;
|
||||
var it = decoded.mimeIterator();
|
||||
while (it.next()) |mime| {
|
||||
if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
|
||||
targets = true;
|
||||
continue;
|
||||
}
|
||||
if (len == mimes_buf.len) continue;
|
||||
mimes_buf[len] = mime;
|
||||
len += 1;
|
||||
}
|
||||
break :mimes .{ mimes_buf[0..len], targets };
|
||||
};
|
||||
|
||||
// Per the spec a password without a name is no password. A
|
||||
// stored grant for it lets the embedder skip its prompt.
|
||||
const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
|
||||
const granted = self.kitty_clipboard_grants.use(alloc, pw, .read);
|
||||
|
||||
var state: KittyClipboardReadState = .{
|
||||
.handler = self,
|
||||
.primary = meta.loc == .primary,
|
||||
.id = meta.id,
|
||||
.pw = pw,
|
||||
.mimes = mimes,
|
||||
.list = list,
|
||||
.terminator = terminator,
|
||||
};
|
||||
func(self, .{
|
||||
.location = meta.loc,
|
||||
.mimes = mimes,
|
||||
.list = list,
|
||||
.name = meta.name,
|
||||
.granted = granted,
|
||||
.can_remember = pw.len > 0,
|
||||
.reply_ctx = &state,
|
||||
.reply_fn = &KittyClipboardReadState.reply,
|
||||
});
|
||||
|
||||
// The program is waiting on us, so a callback that returned
|
||||
// without a reply is answered as a denial rather than silence.
|
||||
if (!state.replied) state.respondStatus(.EPERM);
|
||||
}
|
||||
|
||||
/// Reply state for one synchronous Kitty clipboard read. This lives
|
||||
/// on the kittyClipboardRead stack frame, so it is only valid during
|
||||
/// the callback.
|
||||
const KittyClipboardReadState = struct {
|
||||
handler: *Handler,
|
||||
primary: bool,
|
||||
id: []const u8,
|
||||
|
||||
/// The effective password, empty when the request had none.
|
||||
pw: []const u8,
|
||||
|
||||
/// The requested types; only these are served from a reply.
|
||||
mimes: []const []const u8,
|
||||
list: bool,
|
||||
terminator: osc.Terminator,
|
||||
replied: bool = false,
|
||||
|
||||
fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
|
||||
const self: *KittyClipboardReadState = @ptrCast(@alignCast(ctx));
|
||||
if (self.replied) {
|
||||
log.warn("clipboard read replied more than once, ignoring", .{});
|
||||
return;
|
||||
}
|
||||
self.replied = true;
|
||||
|
||||
const success = switch (result) {
|
||||
.denied => return self.respondStatus(.EPERM),
|
||||
.unsupported => return self.respondStatus(.ENOSYS),
|
||||
.busy => return self.respondStatus(.EBUSY),
|
||||
.io_error => return self.respondStatus(.EIO),
|
||||
.success => |s| s,
|
||||
};
|
||||
|
||||
// Remembering is only offered when the request carried a
|
||||
// usable password.
|
||||
if (success.remember and self.pw.len > 0) {
|
||||
self.handler.kitty_clipboard_grants.grant(
|
||||
self.handler.terminal.gpa(),
|
||||
self.pw,
|
||||
.read,
|
||||
false,
|
||||
) catch |err| {
|
||||
log.warn("error recording clipboard grant err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
self.respondSuccess(&success) catch |err| {
|
||||
log.warn("error replying to clipboard read err={}", .{err});
|
||||
self.respondStatus(.EIO);
|
||||
};
|
||||
}
|
||||
|
||||
/// Answer with a single status packet.
|
||||
fn respondStatus(
|
||||
self: *const KittyClipboardReadState,
|
||||
status: kitty_clipboard.Status,
|
||||
) void {
|
||||
self.handler.kittyClipboardRespond(&.{
|
||||
.op = .read,
|
||||
.status = status,
|
||||
.id = self.id,
|
||||
.terminator = self.terminator,
|
||||
});
|
||||
}
|
||||
|
||||
/// Answer with the full success sequence (OK, listing, DATA
|
||||
/// chunks, DONE), serving only the requested representations
|
||||
/// in request order.
|
||||
fn respondSuccess(
|
||||
self: *const KittyClipboardReadState,
|
||||
success: *const clipboard.Read.Result.Success,
|
||||
) error{ OutOfMemory, WriteFailed }!void {
|
||||
const handler = self.handler;
|
||||
if (handler.effects.write_pty == null) return;
|
||||
|
||||
var served_buf: [kitty_clipboard.max_read_mimes]clipboard.Content = undefined;
|
||||
var served_len: usize = 0;
|
||||
for (self.mimes) |mime| {
|
||||
for (success.contents) |content| {
|
||||
if (!std.mem.eql(u8, content.mime, mime)) continue;
|
||||
served_buf[served_len] = content;
|
||||
served_len += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Status packets fit on the stack; DATA packets carry the
|
||||
// clipboard contents and fall back to the heap.
|
||||
var stack = std.heap.stackFallback(1024, handler.terminal.gpa());
|
||||
const alloc = stack.get();
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
try (kitty_clipboard.ReadSuccess{
|
||||
.primary = self.primary,
|
||||
.id = self.id,
|
||||
.list = self.list,
|
||||
.available = success.available,
|
||||
.contents = served_buf[0..served_len],
|
||||
.terminator = self.terminator,
|
||||
}).encode(&aw.writer);
|
||||
|
||||
const written = try aw.toOwnedSliceSentinel(0);
|
||||
defer alloc.free(written);
|
||||
handler.writePty(written);
|
||||
}
|
||||
};
|
||||
|
||||
fn kittyClipboardWriteBegin(
|
||||
self: *Handler,
|
||||
meta: *const kitty_clipboard.Metadata,
|
||||
@@ -3159,6 +3334,20 @@ const KittyClipboardCapture = struct {
|
||||
var last_data: [8][256]u8 = undefined;
|
||||
var last_data_lens: [8]usize = @splat(0);
|
||||
|
||||
// Read capture. A null read_result returns without replying.
|
||||
var read_count: usize = 0;
|
||||
var read_result: ?clipboard.Read.Result = null;
|
||||
var read_reply_twice: bool = false;
|
||||
var last_read_location: clipboard.Location = .standard;
|
||||
var last_read_mimes: [8][64]u8 = undefined;
|
||||
var last_read_mime_lens: [8]usize = @splat(0);
|
||||
var last_read_mimes_len: usize = 0;
|
||||
var last_read_list: bool = false;
|
||||
var last_read_name: [64]u8 = undefined;
|
||||
var last_read_name_len: usize = 0;
|
||||
var last_read_granted: bool = false;
|
||||
var last_read_can_remember: bool = false;
|
||||
|
||||
fn reset() void {
|
||||
responses_len = 0;
|
||||
write_count = 0;
|
||||
@@ -3167,6 +3356,16 @@ const KittyClipboardCapture = struct {
|
||||
last_contents_len = 0;
|
||||
last_mime_lens = @splat(0);
|
||||
last_data_lens = @splat(0);
|
||||
read_count = 0;
|
||||
read_result = null;
|
||||
read_reply_twice = false;
|
||||
last_read_location = .standard;
|
||||
last_read_mime_lens = @splat(0);
|
||||
last_read_mimes_len = 0;
|
||||
last_read_list = false;
|
||||
last_read_name_len = 0;
|
||||
last_read_granted = false;
|
||||
last_read_can_remember = false;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
@@ -3187,10 +3386,35 @@ const KittyClipboardCapture = struct {
|
||||
return result;
|
||||
}
|
||||
|
||||
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
|
||||
read_count += 1;
|
||||
last_read_location = read.location;
|
||||
last_read_mimes_len = read.mimes.len;
|
||||
for (read.mimes[0..@min(read.mimes.len, last_read_mimes.len)], 0..) |mime, i| {
|
||||
last_read_mime_lens[i] = mime.len;
|
||||
@memcpy(last_read_mimes[i][0..mime.len], mime);
|
||||
}
|
||||
last_read_list = read.list;
|
||||
last_read_name_len = read.name.len;
|
||||
@memcpy(last_read_name[0..read.name.len], read.name);
|
||||
last_read_granted = read.granted;
|
||||
last_read_can_remember = read.can_remember;
|
||||
if (read_result) |r| read.reply(r);
|
||||
if (read_reply_twice) read.reply(.denied);
|
||||
}
|
||||
|
||||
fn responseSlice() []const u8 {
|
||||
return responses[0..responses_len];
|
||||
}
|
||||
|
||||
fn readMimeAt(i: usize) []const u8 {
|
||||
return last_read_mimes[i][0..last_read_mime_lens[i]];
|
||||
}
|
||||
|
||||
fn readName() []const u8 {
|
||||
return last_read_name[0..last_read_name_len];
|
||||
}
|
||||
|
||||
fn mimeAt(i: usize) []const u8 {
|
||||
return last_mimes[i][0..last_mime_lens[i]];
|
||||
}
|
||||
@@ -3321,7 +3545,7 @@ test "kitty clipboard write without clipboard effect responds ENOSYS" {
|
||||
);
|
||||
}
|
||||
|
||||
test "kitty clipboard read is denied with EPERM" {
|
||||
test "kitty clipboard read without effect is denied with EPERM" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
@@ -3356,6 +3580,210 @@ test "kitty clipboard read is denied with EPERM" {
|
||||
try testing.expectEqual(@as(usize, 0), S.responses_len);
|
||||
}
|
||||
|
||||
test "kitty clipboard read round trip" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = KittyClipboardCapture;
|
||||
S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_read = &S.clipboardRead;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
S.read_result = .{
|
||||
.success = .{
|
||||
.contents = &.{
|
||||
// Unrequested representations are never served, and the
|
||||
// served ones follow request order, not reply order.
|
||||
.{ .mime = "image/png", .data = "\x89PNG" },
|
||||
.{ .mime = "text/html", .data = "<b>hi</b>" },
|
||||
.{ .mime = "text/plain", .data = "Ghostty" },
|
||||
},
|
||||
.available = &.{ "text/plain", "text/html" },
|
||||
},
|
||||
};
|
||||
|
||||
// Request the targets listing plus two types from the primary
|
||||
// selection: ". text/plain text/html".
|
||||
s.nextSlice("\x1B]5522;type=read:loc=primary:id=r1;LiB0ZXh0L3BsYWluIHRleHQvaHRtbA==\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 1), S.read_count);
|
||||
try testing.expectEqual(clipboard.Location.primary, S.last_read_location);
|
||||
try testing.expectEqual(@as(usize, 2), S.last_read_mimes_len);
|
||||
try testing.expectEqualStrings("text/plain", S.readMimeAt(0));
|
||||
try testing.expectEqualStrings("text/html", S.readMimeAt(1));
|
||||
try testing.expect(S.last_read_list);
|
||||
try testing.expectEqualStrings("", S.readName());
|
||||
try testing.expect(!S.last_read_granted);
|
||||
try testing.expect(!S.last_read_can_remember);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=read:status=OK:loc=primary:id=r1\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DATA:id=r1:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DONE:id=r1\x1B\\",
|
||||
S.responseSlice(),
|
||||
);
|
||||
|
||||
// Without the listing request `available` is ignored. The response
|
||||
// echoes the request terminator.
|
||||
S.responses_len = 0;
|
||||
s.nextSlice("\x1B]5522;type=read:id=r2;dGV4dC9wbGFpbg==\x07");
|
||||
try testing.expectEqual(clipboard.Location.standard, S.last_read_location);
|
||||
try testing.expectEqual(@as(usize, 1), S.last_read_mimes_len);
|
||||
try testing.expect(!S.last_read_list);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=read:status=OK:id=r2\x07" ++
|
||||
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x07" ++
|
||||
"\x1B]5522;type=read:status=DONE:id=r2\x07",
|
||||
S.responseSlice(),
|
||||
);
|
||||
|
||||
// A listing-only request carries no types.
|
||||
S.responses_len = 0;
|
||||
s.nextSlice("\x1B]5522;type=read;Lg==\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
|
||||
try testing.expect(S.last_read_list);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=read:status=OK\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DONE\x1B\\",
|
||||
S.responseSlice(),
|
||||
);
|
||||
}
|
||||
|
||||
test "kitty clipboard read result maps to response status" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = KittyClipboardCapture;
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_read = &S.clipboardRead;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
const cases = [_]struct {
|
||||
result: ?clipboard.Read.Result,
|
||||
response: []const u8,
|
||||
}{
|
||||
.{ .result = .denied, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
|
||||
.{ .result = .unsupported, .response = "\x1B]5522;type=read:status=ENOSYS:id=x\x1B\\" },
|
||||
.{ .result = .busy, .response = "\x1B]5522;type=read:status=EBUSY:id=x\x1B\\" },
|
||||
.{ .result = .io_error, .response = "\x1B]5522;type=read:status=EIO:id=x\x1B\\" },
|
||||
// No reply at all is a denial rather than silence.
|
||||
.{ .result = null, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
|
||||
// A success with nothing to serve is still OK then DONE.
|
||||
.{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=read:status=OK:id=x\x1B\\" ++
|
||||
"\x1B]5522;type=read:status=DONE:id=x\x1B\\" },
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
S.reset();
|
||||
S.read_result = case.result;
|
||||
s.nextSlice("\x1B]5522;type=read:id=x;dGV4dC9wbGFpbg==\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 1), S.read_count);
|
||||
try testing.expectEqualStrings(case.response, S.responseSlice());
|
||||
}
|
||||
|
||||
// A second reply is ignored.
|
||||
S.reset();
|
||||
S.read_result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
|
||||
S.read_reply_twice = true;
|
||||
s.nextSlice("\x1B]5522;type=read;dGV4dC9wbGFpbg==\x1B\\");
|
||||
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=DONE\x1B\\",
|
||||
S.responseSlice(),
|
||||
);
|
||||
}
|
||||
|
||||
test "kitty clipboard read caps requested types" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = KittyClipboardCapture;
|
||||
S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_read = &S.clipboardRead;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
// "a/0 a/1 a/2 a/3 a/4 a/5 .": extras are dropped but the listing
|
||||
// request after them still counts.
|
||||
S.read_result = .{ .success = .{} };
|
||||
s.nextSlice("\x1B]5522;type=read;YS8wIGEvMSBhLzIgYS8zIGEvNCBhLzUgLg==\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 1), S.read_count);
|
||||
try testing.expectEqual(kitty_clipboard.max_read_mimes, S.last_read_mimes_len);
|
||||
try testing.expectEqualStrings("a/0", S.readMimeAt(0));
|
||||
try testing.expectEqualStrings("a/3", S.readMimeAt(kitty_clipboard.max_read_mimes - 1));
|
||||
try testing.expect(S.last_read_list);
|
||||
}
|
||||
|
||||
test "kitty clipboard read password grants" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = KittyClipboardCapture;
|
||||
S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_read = &S.clipboardRead;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
// pw="secret", name="app": the first request isn't granted but the
|
||||
// reply may ask to remember it.
|
||||
S.read_result = .{ .success = .{ .remember = true } };
|
||||
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 1), S.read_count);
|
||||
try testing.expectEqualStrings("app", S.readName());
|
||||
try testing.expect(!S.last_read_granted);
|
||||
try testing.expect(S.last_read_can_remember);
|
||||
|
||||
// The same password is now granted; a different one is not.
|
||||
S.read_result = .{ .success = .{} };
|
||||
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
|
||||
try testing.expect(S.last_read_granted);
|
||||
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
|
||||
try testing.expect(!S.last_read_granted);
|
||||
try testing.expect(S.last_read_can_remember);
|
||||
|
||||
// A password without a name doesn't count: it is neither granted
|
||||
// nor rememberable, even if the reply asks.
|
||||
S.read_result = .{ .success = .{ .remember = true } };
|
||||
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0\x1B\\");
|
||||
try testing.expectEqualStrings("", S.readName());
|
||||
try testing.expect(!S.last_read_granted);
|
||||
try testing.expect(!S.last_read_can_remember);
|
||||
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=\x1B\\");
|
||||
try testing.expect(!S.last_read_can_remember);
|
||||
S.read_result = .{ .success = .{} };
|
||||
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
|
||||
try testing.expect(!S.last_read_granted);
|
||||
|
||||
// A grant is advisory: the request is still forwarded and the
|
||||
// embedder may deny it.
|
||||
S.responses_len = 0;
|
||||
S.read_result = .denied;
|
||||
s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw\x1B\\");
|
||||
try testing.expect(S.last_read_granted);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=read:status=EPERM:id=d\x1B\\",
|
||||
S.responseSlice(),
|
||||
);
|
||||
|
||||
// Grants are freed with the stream (the testing allocator catches
|
||||
// the leak otherwise).
|
||||
}
|
||||
|
||||
test "kitty clipboard malformed packets are silently dropped" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user