From 6959fd46c6ea7e6a2e5c2f9c680158db2f6f82f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 14:41:07 -0700 Subject: [PATCH 1/9] libghostty: implement Kitty clipboard protocol write only This implements only the clipboard _write_ side of the Kitty clipboard protocol for libghostty-vt. libghostty users don't need to do anything, this all automatically works since it just piggy-backs on the previous clipboard write effect. Clipboard reading is far more complicated because we don't have anything designed yet for libghostty-vt that does async requests (e.g. to ask the user for permission). I need to think about that more. --- include/ghostty/vt/terminal.h | 31 +- src/lib_vt.zig | 2 +- src/terminal/c/terminal.zig | 142 ++++++- src/terminal/stream_terminal.zig | 662 ++++++++++++++++++++++++++++++- 4 files changed, 812 insertions(+), 25 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 89ae241d6..80fdad8c3 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -95,8 +95,8 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS query (CSI 14/16/18 t) or mode 2048 enable | * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| - * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | - * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" | + * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 / OSC 5522 | + * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" / OSC 5522 | * | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 | * | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 | * | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier | @@ -494,7 +494,10 @@ typedef struct { * Result of a clipboard write callback. * * Protocols without write acknowledgements, including OSC 52 and iTerm2 - * OSC 1337 Copy, ignore this result. + * OSC 1337 Copy, ignore this result. The Kitty clipboard protocol + * (OSC 5522) acknowledges writes: each result maps to the corresponding + * protocol status (DONE, EPERM, ENOSYS, EBUSY, EINVAL, EIO) and is + * reported back to the running program through the write_pty callback. * * @ingroup terminal */ @@ -525,9 +528,18 @@ typedef enum GHOSTTY_ENUM_TYPED { * Called synchronously for a complete logical clipboard write. Protocol * details such as OSC 52 selectors, base64 encoding, multipart chunks, * aliases, and terminators are normalized before this callback is invoked. - * OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback - * shape. OSC 52 clipboard read requests ("?") are delivered to - * GhosttyTerminalClipboardReadFn instead. + * OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes + * therefore use the same callback shape. + * + * Every invocation is one complete write: the contents replace whatever + * the destination previously held, so there is never a partial update to + * detect or a reset to perform. A Kitty clipboard write transaction + * results in exactly one invocation, at commit, carrying all of the + * transaction's MIME representations together; its protocol response is + * generated automatically from the returned result. + * + * Clipboard read requests (OSC 52 "?" and OSC 5522 reads) are delivered + * to GhosttyTerminalClipboardReadFn instead. * * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA @@ -1243,9 +1255,10 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Callback invoked when the running program performs a clipboard write. - * OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set - * of decoded MIME representations. Set to NULL to ignore clipboard writes. - * Clipboard read requests are delivered to + * OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes + * are normalized to an atomic set of decoded MIME representations. Set + * to NULL to ignore clipboard writes (Kitty clipboard writes are then + * refused with ENOSYS). Clipboard read requests are delivered to * GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead. * * Input type: GhosttyTerminalClipboardWriteFn diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1cc5c9ed3..414f6995b 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -50,10 +50,10 @@ pub const sys = terminal.sys; pub const TinyIo = @import("lib/TinyIo.zig"); pub const apc = terminal.apc; +pub const clipboard = terminal.clipboard; pub const dcs = terminal.dcs; pub const osc = terminal.osc; pub const point = terminal.point; -pub const clipboard = terminal.clipboard; pub const color = terminal.color; pub const device_status = terminal.device_status; pub const formatter = terminal.formatter; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 947504582..ab45fdfff 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -240,8 +240,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 trampoline is -/// installed dynamically to preserve its null fast path. +/// 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). const Effects = struct { userdata: ?*anyopaque = null, write_pty: ?WritePtyFn = null, @@ -660,7 +663,9 @@ fn wrap( .pwd_changed = &Effects.pwdChangedTrampoline, .progress_report = &Effects.progressReportTrampoline, .size = &Effects.sizeTrampoline, - .clipboard_write = &Effects.clipboardWriteTrampoline, + + // Installed dynamically when the callback is set; see Effects. + .clipboard_write = null, .clipboard_read = null, }; @@ -1244,7 +1249,13 @@ fn setTyped( .pwd_changed => wrapper.effects.pwd_changed = value, .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, - .clipboard_write => wrapper.effects.clipboard_write = value, + .clipboard_write => { + wrapper.effects.clipboard_write = value; + wrapper.stream.handler.effects.clipboard_write = if (value != null) + &Effects.clipboardWriteTrampoline + else + null; + }, .clipboard_read => { wrapper.effects.clipboard_read = value; wrapper.stream.handler.effects.clipboard_read = if (value != null) @@ -4717,8 +4728,10 @@ test "set clipboard_write callback" { try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]); try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]); - // Removing the callback takes effect immediately. + // Removing the callback takes effect immediately and uninstalls + // the trampoline. try testing.expectEqual(Result.success, set(t, .clipboard_write, null)); + try testing.expect(t.?.stream.handler.effects.clipboard_write == null); const after_remove = "\x1B]52;c;eA==\x1B\\"; vt_write(t, after_remove, after_remove.len); try testing.expectEqual(@as(usize, 7), S.count); @@ -4738,12 +4751,119 @@ test "clipboard_write without callback is unsupported and silent" { const seq = "\x1B]52;c;aGVsbG8=\x1B\\"; vt_write(t, seq, seq.len); - const handler = &t.?.stream.handler; - const result = handler.effects.clipboard_write.?(handler, .{ - .location = .standard, - .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, - }); - try testing.expectEqual(clipboard.WriteResult.unsupported, result); + // No trampoline is installed until a callback is set, so the + // stream skips clipboard work (and never spools a Kitty clipboard + // transaction it can't deliver). + try testing.expect(t.?.stream.handler.effects.clipboard_write == null); +} + +test "kitty clipboard write via C effects" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var responses: [512]u8 = undefined; + var responses_len: usize = 0; + var write_count: usize = 0; + var last_location: clipboard.Location = .standard; + var last_contents_len: usize = 0; + var last_mimes: [4][64]u8 = undefined; + var last_mime_lens: [4]usize = @splat(0); + var last_data: [4][64]u8 = undefined; + var last_data_lens: [4]usize = @splat(0); + + fn writePty( + _: Terminal, + _: ?*anyopaque, + ptr: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) void { + @memcpy(responses[responses_len..][0..len], ptr[0..len]); + responses_len += len; + } + + fn clipboardWrite( + _: Terminal, + _: ?*anyopaque, + request: *const ClipboardWrite, + ) callconv(lib.calling_conv) clipboard.WriteResult { + write_count += 1; + last_location = request.location; + last_contents_len = request.contents_len; + if (request.contents) |ptr| { + for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| { + last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len); + @memcpy( + last_mimes[i][0..last_mime_lens[i]], + content.mime.ptr[0..last_mime_lens[i]], + ); + last_data_lens[i] = @min(content.data.len, last_data[i].len); + @memcpy( + last_data[i][0..last_data_lens[i]], + content.data.ptr[0..last_data_lens[i]], + ); + } + } + return .success; + } + }; + S.responses_len = 0; + S.write_count = 0; + S.last_mime_lens = @splat(0); + S.last_data_lens = @splat(0); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); + + // A full OSC 5522 write transaction: begin, chunked data for two + // representations, commit. Only the commit invokes the callback, + // and its result maps to the DONE response. + const seqs = [_][]const u8{ + "\x1B]5522;type=write:id=c1\x1B\\", + "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\", // "Ghost" + "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\", // "ty" + "\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\", // "hi" + "\x1B]5522;type=wdata\x1B\\", + }; + for (seqs) |seq| vt_write(t, seq.ptr, seq.len); + + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqual(@as(usize, 2), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); + try testing.expectEqualStrings("Ghostty", S.last_data[0][0..S.last_data_lens[0]]); + try testing.expectEqualStrings("text/html", S.last_mimes[1][0..S.last_mime_lens[1]]); + try testing.expectEqualStrings("hi", S.last_data[1][0..S.last_data_lens[1]]); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=c1\x1B\\", + S.responses[0..S.responses_len], + ); + + // Reads are always denied. + S.responses_len = 0; + const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\"; + vt_write(t, read, read.len); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM:id=r1\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; + const begin = "\x1B]5522;type=write:id=c2\x1B\\"; + vt_write(t, begin, begin.len); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=c2\x1B\\", + S.responses[0..S.responses_len], + ); } test "set clipboard_read callback" { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 10f239831..f0db5f773 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -14,6 +14,7 @@ const color = @import("color.zig"); const modes = @import("modes.zig"); const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); +const kitty_clipboard = @import("kitty/clipboard.zig"); const kitty_color = @import("kitty/color.zig"); const size_report = @import("size_report.zig"); const simd = @import("../simd/main.zig"); @@ -71,6 +72,11 @@ pub const Handler = struct { /// The DCS command handler maintains state for DCS queries. dcs_handler: dcs.Handler = .{}, + /// The in-flight Kitty clipboard protocol (OSC 5522) write + /// transaction, if any. Null means no transaction is active. + /// Heap-allocated since transactions are rare and short-lived. + kitty_clipboard_write: ?*kitty_clipboard.WriteState = null, + /// 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` @@ -145,8 +151,17 @@ pub const Handler = struct { /// A write with no contents clears the destination. A content entry /// with empty data is a distinct empty representation. /// - /// Clipboard read requests (OSC 52 with a "?" payload) are - /// delivered to clipboard_read instead. + /// OSC 52, OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes all + /// share this callback. Every call is one complete write whose + /// contents replace whatever the destination previously held; there + /// is never a partial update. A Kitty clipboard write transaction + /// results in exactly one call, at commit, carrying all of the + /// transaction's representations, and the returned result is + /// reported back to the running program as the commit status (see + /// kittyClipboard). + /// + /// Clipboard read requests (OSC 52 with a "?" payload and OSC 5522 + /// reads) are delivered to clipboard_read instead. clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult, /// Called when the running program requests clipboard contents @@ -207,6 +222,7 @@ pub const Handler = struct { } pub fn deinit(self: *Handler) void { + self.kittyClipboardAbort(); self.apc_handler.deinit(); self.dcs_handler.deinit(); } @@ -374,6 +390,11 @@ pub const Handler = struct { .kitty_color_report => self.kittyColorOperation(value) catch |err| { log.warn("error reporting Kitty colors err={}", .{err}); }, + .kitty_clipboard => self.kittyClipboard(value) catch |err| { + // Clipboard writes are external effects, not terminal + // state; a failed transaction was already answered. + log.warn("error handling kitty clipboard err={}", .{err}); + }, // APC .apc_start => self.apc_handler.start(), @@ -411,8 +432,6 @@ pub const Handler = struct { // Have no terminal-modifying effect .title_push, .title_pop, - // Unimplemented; the sequence is consumed and ignored. - .kitty_clipboard, => {}, } } @@ -655,6 +674,237 @@ pub const Handler = struct { } }; + /// Handle one Kitty clipboard protocol (OSC 5522) packet. + fn kittyClipboard( + self: *Handler, + v: Action.Value(.kitty_clipboard), + ) error{OutOfMemory}!void { + // Decode and validate the metadata. + var arena: std.heap.ArenaAllocator = .init(self.terminal.gpa()); + defer arena.deinit(); + const meta = (try kitty_clipboard.Metadata.parse( + arena.allocator(), + v.metadata, + )) orelse return; + + const payload = v.payload orelse ""; + switch (meta.op) { + .read => try self.kittyClipboardRead(&meta, payload, v.terminator), + .write => try self.kittyClipboardWriteBegin(&meta, v.terminator), + .wdata => try self.kittyClipboardData(&meta, payload, v.terminator), + .walias => try self.kittyClipboardAlias(&meta, payload, v.terminator), + } + } + + fn kittyClipboardRead( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + 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. + const alloc = self.terminal.gpa(); + const decoded = kitty_clipboard.Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => return, + }; + decoded.deinit(alloc); + + // For now, EPERM always + self.kittyClipboardRespond(&.{ + .op = .read, + .status = .EPERM, + .id = meta.id, + .terminator = terminator, + }); + } + + fn kittyClipboardWriteBegin( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // A new write silently replaces any in-flight transaction. + self.kittyClipboardAbort(); + + // Without a clipboard_write effect a commit can never succeed, + // so fail the transaction up front instead of spooling data + // we'd only throw away. Later wdata packets are ignored. + if (self.effects.clipboard_write == null) { + self.kittyClipboardRespond(&.{ + .op = .write, + .status = .ENOSYS, + .id = meta.id, + .terminator = terminator, + }); + return; + } + + // Setup our write state + const alloc = self.terminal.gpa(); + const state = try alloc.create(kitty_clipboard.WriteState); + errdefer alloc.destroy(state); + state.* = try .init(alloc, meta); + self.kitty_clipboard_write = state; + } + + fn kittyClipboardData( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + payload: []const u8, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // Data without a transaction is silently ignored. + const state = self.kitty_clipboard_write orelse return; + + // A wdata packet without a MIME type commits the transaction. + if (meta.mime.len == 0) return self.kittyClipboardCommit( + state, + terminator, + ); + + state.data( + self.terminal.gpa(), + meta, + payload, + ) catch |err| switch (err) { + // Failing to spool matches kitty's EIO for a failed buffer + // write. + error.OutOfMemory => { + self.kittyClipboardFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + }; + } + + fn kittyClipboardAlias( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + payload: []const u8, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // Aliases without a transaction or without a target MIME type + // are silently ignored. + const state = self.kitty_clipboard_write orelse return; + if (meta.mime.len == 0) return; + + state.alias( + self.terminal.gpa(), + meta, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => { + self.kittyClipboardFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // An undecodable alias payload aborts the transaction. + error.Invalid => self.kittyClipboardFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + fn kittyClipboardCommit( + self: *Handler, + state: *kitty_clipboard.WriteState, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + const alloc = self.terminal.gpa(); + const committed = state.commit(alloc) catch |err| switch (err) { + error.OutOfMemory => { + self.kittyClipboardFinish(state, .EIO, terminator); + return error.OutOfMemory; + }, + }; + defer committed.deinit(alloc); + + // The effect result maps 1:1 onto the protocol's commit + // statuses. The effect can't be null here (checked when the + // transaction began) but if an embedder cleared it + // mid-transaction that's ENOSYS. + const result: clipboard.WriteResult = if (self.effects.clipboard_write) |func| + func(self, .{ + .location = committed.loc, + .contents = committed.contents, + }) + else + .unsupported; + + self.kittyClipboardFinish(state, switch (result) { + .success => .DONE, + .denied => .EPERM, + .unsupported => .ENOSYS, + .busy => .EBUSY, + .invalid_data => .EINVAL, + .io_error, _ => .EIO, + }, terminator); + } + + /// Answer a write transaction with its final status and drop it. + /// The id echoed is the one from the transaction's opening write + /// packet, matching kitty. + fn kittyClipboardFinish( + self: *Handler, + state: *const kitty_clipboard.WriteState, + status: kitty_clipboard.Status, + terminator: osc.Terminator, + ) void { + self.kittyClipboardRespond(&.{ + .op = .write, + .status = status, + .id = state.id, + .terminator = terminator, + }); + self.kittyClipboardAbort(); + } + + /// Drop any in-flight write transaction without responding. + fn kittyClipboardAbort(self: *Handler) void { + if (self.kitty_clipboard_write) |state| { + const alloc = self.terminal.gpa(); + state.deinit(alloc); + alloc.destroy(state); + self.kitty_clipboard_write = null; + } + } + + /// Encode and write a single response packet. Unlike kitty, which + /// always terminates responses with ST, we echo the terminator of + /// the request being answered, matching our other OSC responses. + fn kittyClipboardRespond( + self: *Handler, + response: *const kitty_clipboard.Response, + ) void { + if (self.effects.write_pty == null) return; + + // Our responses carry at most a status and the echoed id so + // they virtually always fit on the stack. + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + response.encode(&aw.writer) catch return; + const resp = aw.toOwnedSliceSentinel(0) catch return; + defer alloc.free(resp); + self.writePty(resp); + } + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { const func = self.effects.device_attributes orelse return; const attrs = func(self); @@ -2895,6 +3145,410 @@ test "clipboard_write allocation failure is ignored" { try testing.expect(!s.handler.semantic_failure); } +/// Shared capture state for the Kitty clipboard (OSC 5522) tests below: +/// records every pty response and the most recent clipboard write. +const KittyClipboardCapture = struct { + var responses: [1024]u8 = undefined; + var responses_len: usize = 0; + var write_count: usize = 0; + var result: clipboard.WriteResult = .success; + var last_location: clipboard.Location = .standard; + var last_contents_len: usize = 0; + var last_mimes: [8][64]u8 = undefined; + var last_mime_lens: [8]usize = @splat(0); + var last_data: [8][256]u8 = undefined; + var last_data_lens: [8]usize = @splat(0); + + fn reset() void { + responses_len = 0; + write_count = 0; + result = .success; + last_location = .standard; + last_contents_len = 0; + last_mime_lens = @splat(0); + last_data_lens = @splat(0); + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + @memcpy(responses[responses_len..][0..data.len], data); + responses_len += data.len; + } + + fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult { + write_count += 1; + last_location = write.location; + last_contents_len = write.contents.len; + for (write.contents[0..@min(write.contents.len, last_mimes.len)], 0..) |content, i| { + last_mime_lens[i] = content.mime.len; + @memcpy(last_mimes[i][0..content.mime.len], content.mime); + last_data_lens[i] = content.data.len; + @memcpy(last_data[i][0..content.data.len], content.data); + } + return result; + } + + fn responseSlice() []const u8 { + return responses[0..responses_len]; + } + + fn mimeAt(i: usize) []const u8 { + return last_mimes[i][0..last_mime_lens[i]]; + } + + fn dataAt(i: usize) []const u8 { + return last_data[i][0..last_data_lens[i]]; + } +}; + +test "kitty clipboard write transaction 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Begin a write, stream two MIME types (one chunked), alias the + // plain text, and commit. Only the commit produces a response. + s.nextSlice("\x1B]5522;type=write:id=42\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); // "Ghost" + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\"); // "ty" + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\"); // "hi" + // Alias "TEXT UTF8_STRING" -> text/plain. + s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVCBVVEY4X1NUUklORw==\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); + + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqual(@as(usize, 4), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.mimeAt(0)); + try testing.expectEqualStrings("Ghostty", S.dataAt(0)); + try testing.expectEqualStrings("text/html", S.mimeAt(1)); + try testing.expectEqualStrings("hi", S.dataAt(1)); + try testing.expectEqualStrings("TEXT", S.mimeAt(2)); + try testing.expectEqualStrings("Ghostty", S.dataAt(2)); + try testing.expectEqualStrings("UTF8_STRING", S.mimeAt(3)); + try testing.expectEqualStrings("Ghostty", S.dataAt(3)); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=42\x1B\\", + S.responseSlice(), + ); + + // A commit with no transaction in flight is silently ignored. + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); +} + +test "kitty clipboard write 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + const cases = [_]struct { + result: clipboard.WriteResult, + response: []const u8, + }{ + .{ .result = .success, .response = "\x1B]5522;type=write:status=DONE\x1B\\" }, + .{ .result = .denied, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" }, + .{ .result = .unsupported, .response = "\x1B]5522;type=write:status=ENOSYS\x1B\\" }, + .{ .result = .busy, .response = "\x1B]5522;type=write:status=EBUSY\x1B\\" }, + .{ .result = .invalid_data, .response = "\x1B]5522;type=write:status=EINVAL\x1B\\" }, + .{ .result = .io_error, .response = "\x1B]5522;type=write:status=EIO\x1B\\" }, + }; + + for (cases) |case| { + S.reset(); + S.result = case.result; + + // An immediately-committed write with no data is a clear. + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 0), S.last_contents_len); + try testing.expectEqualStrings(case.response, S.responseSlice()); + } + + // The response echoes the request terminator, unlike kitty which + // always uses ST. + S.reset(); + s.nextSlice("\x1B]5522;type=write:loc=primary\x07"); + s.nextSlice("\x1B]5522;type=wdata\x07"); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE\x07", + S.responseSlice(), + ); +} + +test "kitty clipboard write without clipboard effect responds ENOSYS" { + 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; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // The transaction fails as soon as it begins; the rest of it is + // ignored without further responses. + s.nextSlice("\x1B]5522;type=write:id=x\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\", + S.responseSlice(), + ); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard read is denied with EPERM" { + 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // The denial never includes loc (only OK responses do) and echoes + // the sanitized id. + s.nextSlice("\x1B]5522;type=read:loc=primary:id=*4 2*;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM:id=42\x1B\\", + S.responseSlice(), + ); + + // A missing payload is an empty MIME list, still answered. + S.reset(); + s.nextSlice("\x1B]5522;type=read\x07"); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM\x07", + S.responseSlice(), + ); + + // An undecodable payload is dropped without a response. + S.reset(); + s.nextSlice("\x1B]5522;type=read;!!!\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.responses_len); +} + +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); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Missing type, unknown type, bare metadata record, invalid mime + // base64, and orphaned transaction packets all drop silently. + s.nextSlice("\x1B]5522;loc=primary\x1B\\"); + s.nextSlice("\x1B]5522;type=bobr\x1B\\"); + s.nextSlice("\x1B]5522;type=read:bare\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=!!!;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVA==\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); + try testing.expect(!s.handler.semantic_failure); + + // The terminal is still functional afterwards. + s.nextSlice("ok"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ok", str); +} + +test "kitty clipboard new write replaces in-flight transaction" { + 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write:id=old\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;b2xk\x1B\\"); // "old" + s.nextSlice("\x1B]5522;type=write:id=new\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bmV3\x1B\\"); // "new" + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 1), S.last_contents_len); + try testing.expectEqualStrings("new", S.dataAt(0)); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=new\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard invalid walias payload aborts with EINVAL" { + 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write:id=w\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;!!!\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); + try testing.expect(!s.handler.semantic_failure); + + // The transaction is gone: a commit does nothing further. + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard invalid wdata chunk is skipped" { + 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8=\x1B\\"); // "Hello" + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;!!!bad!!!\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;V29ybGQ=\x1B\\"); // "World" + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings("HelloWorld", S.dataAt(0)); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard in-flight transaction is freed on deinit" { + 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Never committed: stream deinit must free the transaction (the + // testing allocator catches the leak otherwise). + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "kitty clipboard allocation failure is ignored" { + 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_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Only transaction state uses the terminal allocator here. Swap in + // an allocator that always fails, then restore it before teardown. + { + const alloc = t.screens.active.alloc; + t.screens.active.alloc = testing.failing_allocator; + defer t.screens.active.alloc = alloc; + s.nextSlice("\x1B]5522;type=write\x1B\\"); + } + + // Clipboard writes are external effects, best-effort like OSC 52; + // the failed transaction never started and is not a semantic + // failure. + try testing.expect(!s.handler.semantic_failure); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); +} + +test "kitty clipboard without write_pty still commits writes" { + 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.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings("Ghost", S.dataAt(0)); + + // Reads are dropped without a way to respond. + s.nextSlice("\x1B]5522;type=read\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.responses_len); +} + test "request mode DECRQM with write_pty callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); From 4f49dc2b8bfcd8b1a33de8a95d3b1a1a7135496f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 07:02:09 -0700 Subject: [PATCH 2/9] libghostty: implement Kitty clipboard protocol reads via clipboard_read effect --- include/ghostty/vt/terminal.h | 31 +- src/terminal/c/terminal.zig | 75 +++- src/terminal/kitty/clipboard_command.zig | 51 +-- src/terminal/kitty/clipboard_write.zig | 9 +- src/terminal/stream_terminal.zig | 460 ++++++++++++++++++++++- 5 files changed, 568 insertions(+), 58 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 80fdad8c3..b23303605 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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 */ diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ab45fdfff..ba00f1385 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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; diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig index 5f7115d83..40718840a 100644 --- a/src/terminal/kitty/clipboard_command.zig +++ b/src/terminal/kitty/clipboard_command.zig @@ -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" { diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig index c4e33a4f3..fbb631446 100644 --- a/src/terminal/kitty/clipboard_write.zig +++ b/src/terminal/kitty/clipboard_write.zig @@ -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), }; diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index f0db5f773..03e6d1887 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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 = "hi" }, + .{ .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); From 7c845e8af5b2e0dc508f3f64c08383985bb536ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 14:51:23 -0700 Subject: [PATCH 3/9] terminal/kitty: drag and drop command decoding --- src/terminal/kitty.zig | 1 + src/terminal/kitty/dnd.zig | 73 +++++ src/terminal/kitty/dnd_command.zig | 435 +++++++++++++++++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 src/terminal/kitty/dnd.zig create mode 100644 src/terminal/kitty/dnd_command.zig diff --git a/src/terminal/kitty.zig b/src/terminal/kitty.zig index 471fb91e7..bbef8d7bb 100644 --- a/src/terminal/kitty.zig +++ b/src/terminal/kitty.zig @@ -5,6 +5,7 @@ const build_options = @import("terminal_options"); const key = @import("kitty/key.zig"); pub const clipboard = @import("kitty/clipboard.zig"); pub const color = @import("kitty/color.zig"); +pub const dnd = @import("kitty/dnd.zig"); pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {}; pub const KeyFlags = key.Flags; diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig new file mode 100644 index 000000000..5a70b0a6f --- /dev/null +++ b/src/terminal/kitty/dnd.zig @@ -0,0 +1,73 @@ +//! Kitty drag and drop protocol (OSC 72). +//! +//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/ +//! Reference implementation: kitty/dnd.c and kitty/screen.c in +//! https://github.com/kovidgoyal/kitty (introduced in kitty 0.47). +//! +//! The protocol lets a program running in the terminal participate in +//! native OS drag and drop. A client registers to accept drops (t=a); +//! the terminal then forwards native drag movement (t=m) and drops +//! (t=M) to it and serves the dropped data on request (t=r), instead +//! of the traditional behavior of pasting dropped paths or text. +//! +//! The implementation is split into: +//! +//! * dnd_command.zig: metadata grammar and typed command decoding, +//! including chunk reassembly. The grammar mirrors kitty's +//! generated parser exactly. +//! * dnd_response.zig: wire encoding for everything the terminal +//! sends, mirroring kitty's send_payload_to_child chunking. +//! * dnd_drop.zig: the per-terminal protocol state machine, driven +//! by client OSCs on one side and native drag events from the +//! embedder on the other. +//! +//! The wire behavior was validated against kitty's implementation +//! (kitty_tests/dnd.py is the oracle), including its deviations from +//! the published spec: the MIME list payload is sent on every move +//! event with a trailing space after each entry, a missing `t` key +//! ignores the command rather than defaulting to `a`, empty payloads +//! omit the `;` and `m=` entirely, and registration survives a +//! terminal reset (RIS clears only the chunk-reassembly flag). +//! +//! ## Divergences from kitty +//! +//! All are bounded-scope decisions, not accidents: +//! +//! * Dropped data is captured eagerly at drop time from a curated +//! set of representations the embedder can serve (typically +//! text/uri-list and text/plain), rather than fetched from the OS +//! on demand. Consequently the native drag session concludes at +//! drop time and the client's concluding operation (t=r with +//! x=y=Y=0) only frees the held data, and kitty's 128-entry +//! request queue and EMFILE overflow handling are unnecessary +//! because requests are served synchronously in order. +//! * The MIME list a client registers with (the t=a payload) is +//! accepted but not forwarded to the OS, so exotic pasteboard +//! types on macOS are not offered to clients. +//! * Every client is treated as local: machine IDs (t=a:x=1) are +//! accepted and ignored, responses never carry the X=1 remote +//! marker, and remote file transfer requests (t=r with y or Y +//! keys) are answered with EINVAL. A remote client (e.g. over +//! ssh) can still receive text drops; only file-content transfer +//! is unavailable. +//! * The terminal never initiates drags (drag out): enabling offers +//! (t=o:x=1) is tracked so the state is queryable, but the +//! terminal never sends a drag start request, so a conforming +//! client never offers a drag. Direct offers (t=o:x=0) and drag +//! data/start commands (t=p, t=P) are refused with EPERM. +//! * Responses echo the requesting command's terminator (ST or BEL) +//! per ghostty convention; kitty always uses ST. Terminal- +//! initiated events always use ST. + +const dnd_command = @import("dnd_command.zig"); + +pub const EventType = dnd_command.EventType; +pub const Metadata = dnd_command.Metadata; +pub const Operation = dnd_command.Operation; +pub const Operations = dnd_command.Operations; +pub const Request = dnd_command.Request; +pub const Chunking = dnd_command.Chunking; + +test { + _ = dnd_command; +} diff --git a/src/terminal/kitty/dnd_command.zig b/src/terminal/kitty/dnd_command.zig new file mode 100644 index 000000000..f056f1406 --- /dev/null +++ b/src/terminal/kitty/dnd_command.zig @@ -0,0 +1,435 @@ +const std = @import("std"); + +/// Decoded OSC 72 metadata. +pub const Metadata = struct { + /// Event type (`t`). Null when the metadata had no `t` key; such + /// commands parse successfully but are ignored, matching kitty. + type: ?EventType = null, + + /// Chunking flag (`m`): true when more chunks follow. + more: bool = false, + + /// Multiplexer client ID (`i`), echoed in every response so a + /// terminal multiplexer can route responses to the correct client. + client_id: u32 = 0, + + /// Operation (`o`): meaning depends on the event type, commonly + /// 0=none/reject, 1=copy, 2=move, 3=copy or move. + operation: u32 = 0, + + /// `x`, `y`, `X`, `Y` keys. + cell_x: i32 = 0, + cell_y: i32 = 0, + pixel_x: i32 = 0, + pixel_y: i32 = 0, + + /// Parse raw OSC 72 metadata (the part before the first `;`). + /// Returns null when malformed; callers should ignore the command, + /// matching kitty which rejects the entire command on any error. + pub fn parse(raw: []const u8) ?Metadata { + var result: Metadata = .{}; + var pos: usize = 0; + // The continue expression consumes the ':' separating a field + // from the next; the body advances past the field itself. + while (pos < raw.len) : (pos += 1) { + // Single-character key. + const key = raw[pos]; + pos += 1; + switch (key) { + 't', 'm', 'i', 'o', 'x', 'y', 'X', 'Y' => {}, + else => return null, + } + + // '=' separator. + if (pos >= raw.len) return null; + if (raw[pos] != '=') return null; + pos += 1; + + // Value. + if (pos >= raw.len) return null; + switch (key) { + 't' => { + result.type = std.enums.fromInt( + EventType, + raw[pos], + ) orelse return null; + pos += 1; + }, + + 'm', 'i', 'o' => { + const v = parseUnsigned(raw, &pos) orelse return null; + switch (key) { + 'm' => result.more = v != 0, + 'i' => result.client_id = v, + 'o' => result.operation = v, + else => unreachable, + } + }, + + 'x', 'y', 'X', 'Y' => { + const negative = raw[pos] == '-'; + if (negative) pos += 1; + const unsigned = parseUnsigned(raw, &pos) orelse return null; + // Matches kitty's cast of the u32 magnitude to i32, + // which wraps rather than erroring on overflow. + const magnitude: i32 = @bitCast(unsigned); + const v = if (negative) 0 -% magnitude else magnitude; + switch (key) { + 'x' => result.cell_x = v, + 'y' => result.cell_y = v, + 'X' => result.pixel_x = v, + 'Y' => result.pixel_y = v, + else => unreachable, + } + }, + + else => unreachable, + } + + // Values are separated by ':'. + if (pos >= raw.len) break; + if (raw[pos] != ':') return null; + } + + return result; + } + + /// Parse an unsigned decimal value at `pos`, advancing it. At most + /// 10 digits and at most maxInt(u32), matching kitty. Returns null + /// when there are no digits or the value is too large. + fn parseUnsigned(raw: []const u8, pos: *usize) ?u32 { + const start = pos.*; + var acc: u64 = 0; + var i = start; + while (i < raw.len and i < start + 10) : (i += 1) { + const d = raw[i] -% '0'; + if (d > 9) break; + acc = acc * 10 + d; + } + if (i == start) return null; + pos.* = i; + return std.math.cast(u32, acc) orelse null; + } +}; + +/// The event type, i.e. values for the `t` metadata key. A single OSC 72 +/// code is used for both directions of the protocol, so most types have +/// one meaning when received by the terminal from a client and another +/// when sent by the terminal to a client. +/// +/// The `drop` and `request_response` types are only ever sent by the +/// terminal; kitty parses but ignores them when received and we do the +/// same. +pub const EventType = enum(u8) { + /// 'a': (recv) client registers to accept drops. With x=1 the payload + /// is the client's machine ID for remote drop support instead. + register = 'a', + + /// 'A': (recv) client unregisters from accepting drops. + unregister = 'A', + + /// 'm': (recv) client reports acceptance status for the drag currently + /// over the terminal: `o` is the chosen operation and the payload is + /// the accepted MIME list. (send) pointer moved over the terminal + /// during a drag, or with x=-1,y=-1 the drag left the window. + status = 'm', + + /// 'M': (send only) items were dropped onto the terminal. + drop = 'M', + + /// 'r': (recv) client requests drop data, or with x=y=Y=0 concludes + /// the drop with `o` as the performed operation. (send) drop data + /// response chunks. + request = 'r', + + /// 'R': (send only) error response to a data request. + request_error = 'R', + + /// 'o': (recv) drag source control: x=1 enables offering drags (payload + /// optionally the client machine ID), x=2 disables, x=0 offers a MIME + /// list for a new drag. (send) request that the client start a drag at + /// the given position. + offer = 'o', + + /// 'p': (recv) pre-sent data for an offered drag: x>=0 is a 0-based + /// MIME index, x<0 attaches drag image -x. + present = 'p', + + /// 'P': (recv) x=-1 starts the offered drag, x>=0 changes the drag + /// image mid-drag. + start_drag = 'P', + + /// 'e': (recv) drag data for MIME index `y` of an in-progress drag. + /// (send) drag status events (accepted, dropped, finished, ...). + drag_event = 'e', + + /// 'E': (recv) client aborts the whole drag (y=-1) or reports an error + /// for MIME index `y`. (send) drag start response (OK or error). + drag_error = 'E', + + /// 'k': (recv) remote file data for a drag. (send) request for remote + /// file data. + remote_data = 'k', + + /// 'q': (recv) query protocol support. (send) the query response. + query = 'q', +}; + +/// A drop operation. Values match the protocol's `o` key. +pub const Operation = enum(u2) { + none = 0, + copy = 1, + move = 2, + + /// Convert a protocol `o` value the way kitty does: anything other + /// than copy or move means none. + pub fn fromProtocol(v: u32) Operation { + return switch (v) { + 1 => .copy, + 2 => .move, + else => .none, + }; + } +}; + +/// The set of operations allowed by a drag source, sent as a bitmask in +/// the `o` key of move and drop events. +pub const Operations = packed struct(u2) { + copy: bool = false, + move: bool = false, + + pub fn protocolValue(self: Operations) u2 { + return @bitCast(self); + } +}; + +/// A decoded `t=r` data request. The request form is disambiguated by +/// which keys are non-zero, mirroring kitty's drop_process_queue. +pub const Request = union(enum) { + /// x=y=Y=0: the drop is concluded with the given operation. + conclude: Operation, + + /// Y=0, y=0, x!=0: request data for the 1-based MIME index x. + mime: i32, + + /// Y=0, y!=0: request the contents of the y'th (1-based) file in the + /// text/uri-list MIME at 1-based index x. Remote drops only. + uri: struct { + mime_idx: i32, + uri_idx: i32, + }, + + /// Y!=0: request entry x (1-based) of directory handle Y, or close + /// the handle when x=0. Remote drops only. + dir: struct { + handle: i32, + entry: i32, + }, + + pub fn init(meta: Metadata) Request { + if (meta.pixel_y != 0) return .{ .dir = .{ + .handle = meta.pixel_y, + .entry = meta.cell_x, + } }; + if (meta.cell_y != 0) return .{ .uri = .{ + .mime_idx = meta.cell_x, + .uri_idx = meta.cell_y, + } }; + if (meta.cell_x != 0) return .{ .mime = meta.cell_x }; + return .{ .conclude = .fromProtocol(meta.operation) }; + } +}; + +/// Chunk reassembly state, mirroring kitty's per-screen dnd_chunking. +/// While a chunked command is in progress, the metadata of the first +/// chunk is reused for all subsequent chunks; only the `more` flag is +/// taken from each continuation. +pub const Chunking = struct { + active: bool = false, + metadata: Metadata = .{}, + + /// Returns the effective metadata for a received chunk and updates + /// the reassembly state. + pub fn apply(self: *Chunking, meta: Metadata) Metadata { + if (self.active) { + var copy = self.metadata; + copy.more = meta.more; + self.active = meta.more; + return copy; + } + + if (meta.more) { + self.active = true; + self.metadata = meta; + } + + return meta; + } +}; + +test "Metadata: empty" { + const testing = std.testing; + const meta = Metadata.parse("").?; + try testing.expect(meta.type == null); + try testing.expect(!meta.more); + try testing.expectEqual(@as(u32, 0), meta.client_id); +} + +test "Metadata: all keys" { + const testing = std.testing; + const meta = Metadata.parse("t=m:m=1:i=3:o=2:x=10:y=5:X=320:Y=200").?; + try testing.expectEqual(EventType.status, meta.type.?); + try testing.expect(meta.more); + try testing.expectEqual(@as(u32, 3), meta.client_id); + try testing.expectEqual(@as(u32, 2), meta.operation); + try testing.expectEqual(@as(i32, 10), meta.cell_x); + try testing.expectEqual(@as(i32, 5), meta.cell_y); + try testing.expectEqual(@as(i32, 320), meta.pixel_x); + try testing.expectEqual(@as(i32, 200), meta.pixel_y); +} + +test "Metadata: all event types" { + const testing = std.testing; + const cases = .{ + .{ "t=a", EventType.register }, + .{ "t=A", EventType.unregister }, + .{ "t=m", EventType.status }, + .{ "t=M", EventType.drop }, + .{ "t=r", EventType.request }, + .{ "t=R", EventType.request_error }, + .{ "t=o", EventType.offer }, + .{ "t=p", EventType.present }, + .{ "t=P", EventType.start_drag }, + .{ "t=e", EventType.drag_event }, + .{ "t=E", EventType.drag_error }, + .{ "t=k", EventType.remote_data }, + .{ "t=q", EventType.query }, + }; + inline for (cases) |case| { + try testing.expectEqual(case[1], Metadata.parse(case[0]).?.type.?); + } +} + +test "Metadata: case-sensitive coordinate keys" { + const testing = std.testing; + const meta = Metadata.parse("x=10:Y=200").?; + try testing.expectEqual(@as(i32, 10), meta.cell_x); + try testing.expectEqual(@as(i32, 0), meta.cell_y); + try testing.expectEqual(@as(i32, 0), meta.pixel_x); + try testing.expectEqual(@as(i32, 200), meta.pixel_y); +} + +test "Metadata: negative coordinates" { + const testing = std.testing; + const meta = Metadata.parse("t=m:x=-1:y=-1").?; + try testing.expectEqual(@as(i32, -1), meta.cell_x); + try testing.expectEqual(@as(i32, -1), meta.cell_y); +} + +test "Metadata: malformed inputs rejected" { + const testing = std.testing; + // Unknown event type. + try testing.expect(Metadata.parse("t=z") == null); + // Unknown key. + try testing.expect(Metadata.parse("z=1") == null); + // Missing '=' mid-stream. + try testing.expect(Metadata.parse("x10") == null); + // No digits. + try testing.expect(Metadata.parse("x=notanumber") == null); + try testing.expect(Metadata.parse("x=-") == null); + // Too large. + try testing.expect(Metadata.parse("i=4294967296") == null); + try testing.expect(Metadata.parse("i=99999999999") == null); + // Garbage after value. + try testing.expect(Metadata.parse("x=1z") == null); + // No whitespace tolerance, matching kitty. + try testing.expect(Metadata.parse("t=a: x=1") == null); + try testing.expect(Metadata.parse("t = a") == null); + // Truncated mid-construct, matching kitty's final-state check. + // Nothing state-changing may be parsed out of these: e.g. "t=r:x=" + // must not be treated as a drop conclusion. + try testing.expect(Metadata.parse("t") == null); + try testing.expect(Metadata.parse("t=") == null); + try testing.expect(Metadata.parse("x=") == null); + try testing.expect(Metadata.parse("t=r:x=") == null); + try testing.expect(Metadata.parse("x=1:t=") == null); +} + +test "Metadata: trailing separator accepted" { + const testing = std.testing; + + // Kitty's state machine accepts the metadata ending right after a + // value separator. + const meta = Metadata.parse("t=a:").?; + try testing.expectEqual(EventType.register, meta.type.?); +} + +test "Metadata: u32 boundary accepted" { + const testing = std.testing; + const meta = Metadata.parse("i=4294967295").?; + try testing.expectEqual(@as(u32, std.math.maxInt(u32)), meta.client_id); +} + +test "Request: classification" { + const testing = std.testing; + + // Conclude. + { + const r: Request = .init(Metadata.parse("t=r:o=2").?); + try testing.expectEqual(Operation.move, r.conclude); + } + // Conclude with unknown operation is none (canceled). + { + const r: Request = .init(Metadata.parse("t=r:o=9").?); + try testing.expectEqual(Operation.none, r.conclude); + } + // MIME data request. + { + const r: Request = .init(Metadata.parse("t=r:x=2").?); + try testing.expectEqual(@as(i32, 2), r.mime); + } + // URI file request. + { + const r: Request = .init(Metadata.parse("t=r:x=1:y=3").?); + try testing.expectEqual(@as(i32, 1), r.uri.mime_idx); + try testing.expectEqual(@as(i32, 3), r.uri.uri_idx); + } + // Directory handle request. + { + const r: Request = .init(Metadata.parse("t=r:Y=2:x=1").?); + try testing.expectEqual(@as(i32, 2), r.dir.handle); + try testing.expectEqual(@as(i32, 1), r.dir.entry); + } +} + +test "Chunking: reassembly reuses first chunk metadata" { + const testing = std.testing; + var chunking: Chunking = .{}; + + // First chunk starts reassembly. + const first = chunking.apply(Metadata.parse("t=m:o=1:m=1").?); + try testing.expect(chunking.active); + try testing.expectEqual(EventType.status, first.type.?); + try testing.expect(first.more); + + // Continuation metadata is ignored except for `more`. + const second = chunking.apply(Metadata.parse("t=q:o=2:m=1").?); + try testing.expect(chunking.active); + try testing.expectEqual(EventType.status, second.type.?); + try testing.expectEqual(@as(u32, 1), second.operation); + try testing.expect(second.more); + + // Final chunk ends reassembly. + const last = chunking.apply(Metadata.parse("t=q:m=0").?); + try testing.expect(!chunking.active); + try testing.expectEqual(EventType.status, last.type.?); + try testing.expect(!last.more); +} + +test "Chunking: unchunked commands pass through" { + const testing = std.testing; + var chunking: Chunking = .{}; + const meta = chunking.apply(Metadata.parse("t=q").?); + try testing.expect(!chunking.active); + try testing.expectEqual(EventType.query, meta.type.?); +} From 38746b8c14321004edaed584c2bf3d61b1cfd676 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 14:58:15 -0700 Subject: [PATCH 4/9] terminal/kitty: drag and drop response encoding --- src/terminal/kitty/dnd.zig | 7 + src/terminal/kitty/dnd_response.zig | 248 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/terminal/kitty/dnd_response.zig diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig index 5a70b0a6f..c60415c41 100644 --- a/src/terminal/kitty/dnd.zig +++ b/src/terminal/kitty/dnd.zig @@ -60,6 +60,7 @@ //! initiated events always use ST. const dnd_command = @import("dnd_command.zig"); +const dnd_response = @import("dnd_response.zig"); pub const EventType = dnd_command.EventType; pub const Metadata = dnd_command.Metadata; @@ -68,6 +69,12 @@ pub const Operations = dnd_command.Operations; pub const Request = dnd_command.Request; pub const Chunking = dnd_command.Chunking; +pub const Errno = dnd_response.Errno; +pub const RequestKeys = dnd_response.RequestKeys; +pub const encode = dnd_response.encode; +pub const encodeError = dnd_response.encodeError; + test { _ = dnd_command; + _ = dnd_response; } diff --git a/src/terminal/kitty/dnd_response.zig b/src/terminal/kitty/dnd_response.zig new file mode 100644 index 000000000..1f7584f12 --- /dev/null +++ b/src/terminal/kitty/dnd_response.zig @@ -0,0 +1,248 @@ +const std = @import("std"); +const Terminator = @import("../osc.zig").Terminator; + +/// The maximum raw bytes per base64-encoded chunk, chosen by kitty so a +/// chunk is exactly 4096 base64 characters (the protocol's chunk limit). +pub const max_chunk_raw = 3072; + +/// The maximum bytes per plain-text (non-base64) chunk. +pub const max_chunk_plain = 4096; + +/// Error names used in protocol error payloads. The wire encoding is +/// the tag name itself. This matches kitty's get_errno_name vocabulary, +/// which extends the spec's list with EISDIR, ENOSPC, and OK. +pub const Errno = enum { + OK, + EPERM, + ENOENT, + EIO, + EINVAL, + EMFILE, + ENOMEM, + EFBIG, + EISDIR, + ENOSPC, + EUNKNOWN, +}; + +/// The payload encoding for a message. +pub const Encoding = enum { + /// Payload bytes are sent as-is (MIME lists, error strings). + plain, + + /// Payload bytes are base64-encoded (all binary data). + base64, +}; + +/// The `x`/`y`/`Y` keys of the data request currently being answered, +/// echoed in responses and errors so the client can match them up. Only +/// non-zero keys are written, matching kitty's drop_append_request_keys. +pub const RequestKeys = struct { + x: i32 = 0, + y: i32 = 0, + Y: i32 = 0, + + pub fn format(self: RequestKeys, writer: *std.Io.Writer) !void { + if (self.x != 0) try writer.print(":x={d}", .{self.x}); + if (self.y != 0) try writer.print(":y={d}", .{self.y}); + if (self.Y != 0) try writer.print(":Y={d}", .{self.Y}); + } +}; + +/// Encode a complete protocol message: one bare OSC when `data` is +/// empty, otherwise one complete OSC per chunk of `data`, each +/// repeating the header. The final chunk carries `m=0`, earlier +/// chunks `m=1`. +/// +/// `header` is the metadata without the OSC introducer, e.g. "t=q" or +/// "t=m:x=5:y=3". The client ID is appended as `:i=N` when non-zero. +pub fn encode( + writer: *std.Io.Writer, + header: []const u8, + client_id: u32, + data: []const u8, + encoding: Encoding, + terminator: Terminator, +) std.Io.Writer.Error!void { + // The client ID is part of the repeated header. + var id_buf: [16]u8 = undefined; + const id: []const u8 = if (client_id != 0) std.fmt.bufPrint( + &id_buf, + ":i={d}", + .{client_id}, + ) catch unreachable else ""; + + if (data.len == 0) { + try writer.print("\x1b]72;{s}{s}{s}", .{ + header, + id, + terminator.string(), + }); + return; + } + + const limit: usize = switch (encoding) { + .base64 => max_chunk_raw, + .plain => max_chunk_plain, + }; + + var offset: usize = 0; + while (offset < data.len) { + const chunk_len = @min(data.len - offset, limit); + const chunk = data[offset .. offset + chunk_len]; + offset += chunk_len; + const last: u8 = if (offset >= data.len) '0' else '1'; + + try writer.print("\x1b]72;{s}{s}:m={c};", .{ header, id, last }); + switch (encoding) { + .plain => try writer.writeAll(chunk), + .base64 => { + var b64_buf: [std.base64.standard.Encoder.calcSize(max_chunk_raw)]u8 = undefined; + try writer.writeAll(std.base64.standard.Encoder.encode( + &b64_buf, + chunk, + )); + }, + } + try writer.writeAll(terminator.string()); + } +} + +/// Encode an error response. `kind` selects the header: t=R for drop +/// data request errors, t=E for drag offer errors. The payload is +/// "NAME" or "NAME:description", sent plain (not base64). +pub fn encodeError( + writer: *std.Io.Writer, + kind: enum { drop, drag }, + keys: RequestKeys, + client_id: u32, + errno: Errno, + desc: []const u8, + terminator: Terminator, +) std.Io.Writer.Error!void { + var header_buf: [64]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "t={c}{f}", .{ + @as(u8, switch (kind) { + .drop => 'R', + .drag => 'E', + }), + keys, + }) catch unreachable; + + // Description strings are short static messages; size the buffer + // for the longest error name plus a generous description. + var payload_buf: [256]u8 = undefined; + const payload = if (desc.len > 0) std.fmt.bufPrint( + &payload_buf, + "{t}:{s}", + .{ errno, desc }, + ) catch unreachable else @tagName(errno); + + try encode(writer, header, client_id, payload, .plain, terminator); +} + +test "encode: bare message" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=q", 0, "", .plain, .st); + try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", writer.buffered()); +} + +test "encode: bare message with client id" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=q", 7, "", .plain, .st); + try testing.expectEqualStrings("\x1b]72;t=q:i=7\x1b\\", writer.buffered()); +} + +test "encode: plain payload single chunk" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=m:x=1:y=2", 0, "text/plain ", .plain, .st); + try testing.expectEqualStrings( + "\x1b]72;t=m:x=1:y=2:m=0;text/plain \x1b\\", + writer.buffered(), + ); +} + +test "encode: base64 payload chunking" { + const testing = std.testing; + const alloc = testing.allocator; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + // Exactly one byte more than a chunk to force two chunks. + const data = [_]u8{'A'} ** (max_chunk_raw + 1); + try encode(&aw.writer, "t=r:x=1", 3, &data, .base64, .st); + + const out = aw.written(); + + // First chunk: full header, m=1, 4096 base64 chars. + const prefix = "\x1b]72;t=r:x=1:i=3:m=1;"; + try testing.expect(std.mem.startsWith(u8, out, prefix)); + const first_end = std.mem.indexOf(u8, out, "\x1b\\").?; + try testing.expectEqual(@as(usize, prefix.len + 4096), first_end); + + // Second chunk: m=0 with the single remaining byte. + const rest = out[first_end + 2 ..]; + try testing.expect(std.mem.startsWith(u8, rest, "\x1b]72;t=r:x=1:i=3:m=0;")); + + // Decodes back to the original data. + var decoded: std.ArrayList(u8) = .empty; + defer decoded.deinit(alloc); + var it = std.mem.splitSequence(u8, out, "\x1b\\"); + while (it.next()) |osc| { + if (osc.len == 0) continue; + const payload_start = std.mem.indexOfScalar(u8, osc, ';').?; + const payload = osc[std.mem.indexOfScalarPos(u8, osc, payload_start + 1, ';').? + 1 ..]; + const n = try std.base64.standard.Decoder.calcSizeForSlice(payload); + const start = decoded.items.len; + try decoded.resize(alloc, start + n); + try std.base64.standard.Decoder.decode(decoded.items[start..], payload); + } + try testing.expectEqualSlices(u8, &data, decoded.items); +} + +test "encodeError: with and without description" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + { + var writer: std.Io.Writer = .fixed(&buf); + try encodeError(&writer, .drop, .{ .x = 2 }, 0, .ENOENT, "drop data request index out of bounds", .st); + try testing.expectEqualStrings( + "\x1b]72;t=R:x=2:m=0;ENOENT:drop data request index out of bounds\x1b\\", + writer.buffered(), + ); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try encodeError(&writer, .drag, .{}, 5, .EPERM, "", .st); + try testing.expectEqualStrings( + "\x1b]72;t=E:i=5:m=0;EPERM\x1b\\", + writer.buffered(), + ); + } +} + +test "RequestKeys: only non-zero keys written" { + const testing = std.testing; + var buf: [64]u8 = undefined; + + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{}}); + try testing.expectEqualStrings("", s); + } + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{ .x = 1, .y = 2, .Y = 3 }}); + try testing.expectEqualStrings(":x=1:y=2:Y=3", s); + } + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{ .Y = 4 }}); + try testing.expectEqualStrings(":Y=4", s); + } +} From 50f69b883cc75061441deadcbcbee9ecf6fc81b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 09:19:21 -0700 Subject: [PATCH 5/9] terminal/kitty: drag and drop drop state machine --- src/terminal/kitty/dnd.zig | 34 +- src/terminal/kitty/dnd_command.zig | 3 +- src/terminal/kitty/dnd_drop.zig | 715 ++++++++++++++++++ src/terminal/kitty/dnd_test.zig | 626 +++++++++++++++ .../osc/parsers/kitty_dnd_protocol.zig | 313 ++------ 5 files changed, 1410 insertions(+), 281 deletions(-) create mode 100644 src/terminal/kitty/dnd_drop.zig create mode 100644 src/terminal/kitty/dnd_test.zig diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig index c60415c41..42afcb749 100644 --- a/src/terminal/kitty/dnd.zig +++ b/src/terminal/kitty/dnd.zig @@ -19,7 +19,9 @@ //! sends, mirroring kitty's send_payload_to_child chunking. //! * dnd_drop.zig: the per-terminal protocol state machine, driven //! by client OSCs on one side and native drag events from the -//! embedder on the other. +//! embedder on the other. It is allocated when a client registers +//! to accept drops and freed when it unregisters, so terminals +//! that never see the protocol pay nothing for it. //! //! The wire behavior was validated against kitty's implementation //! (kitty_tests/dnd.py is the oracle), including its deviations from @@ -29,9 +31,9 @@ //! omit the `;` and `m=` entirely, and registration survives a //! terminal reset (RIS clears only the chunk-reassembly flag). //! -//! ## Divergences from kitty +//! ## Divergences //! -//! All are bounded-scope decisions, not accidents: +//! These will be fixed in the future: //! //! * Dropped data is captured eagerly at drop time from a curated //! set of representations the embedder can serve (typically @@ -41,26 +43,27 @@ //! x=y=Y=0) only frees the held data, and kitty's 128-entry //! request queue and EMFILE overflow handling are unnecessary //! because requests are served synchronously in order. -//! * The MIME list a client registers with (the t=a payload) is -//! accepted but not forwarded to the OS, so exotic pasteboard -//! types on macOS are not offered to clients. //! * Every client is treated as local: machine IDs (t=a:x=1) are //! accepted and ignored, responses never carry the X=1 remote //! marker, and remote file transfer requests (t=r with y or Y //! keys) are answered with EINVAL. A remote client (e.g. over //! ssh) can still receive text drops; only file-content transfer //! is unavailable. -//! * The terminal never initiates drags (drag out): enabling offers -//! (t=o:x=1) is tracked so the state is queryable, but the -//! terminal never sends a drag start request, so a conforming -//! client never offers a drag. Direct offers (t=o:x=0) and drag -//! data/start commands (t=p, t=P) are refused with EPERM. +//! * The terminal never initiates drags (drag out): enabling and +//! disabling offers (t=o:x=1, t=o:x=2) are accepted and ignored, +//! and since the terminal never sends a drag start request a +//! conforming client never offers a drag. Direct offers (t=o:x=0) +//! and drag data/start commands (t=p, t=P) are refused with EPERM. +//! +//! These are on purpose forever: +//! //! * Responses echo the requesting command's terminator (ST or BEL) //! per ghostty convention; kitty always uses ST. Terminal- //! initiated events always use ST. const dnd_command = @import("dnd_command.zig"); const dnd_response = @import("dnd_response.zig"); +const dnd_drop = @import("dnd_drop.zig"); pub const EventType = dnd_command.EventType; pub const Metadata = dnd_command.Metadata; @@ -74,7 +77,16 @@ pub const RequestKeys = dnd_response.RequestKeys; pub const encode = dnd_response.encode; pub const encodeError = dnd_response.encodeError; +pub const State = dnd_drop.State; +pub const Item = dnd_drop.State.Item; +pub const MoveEvent = dnd_drop.State.MoveEvent; +pub const max_mime_list_bytes = dnd_drop.max_mime_list_bytes; +pub const handleCommand = dnd_drop.handleCommand; +pub const Event = dnd_drop.Event; + test { _ = dnd_command; _ = dnd_response; + _ = dnd_drop; + _ = @import("dnd_test.zig"); } diff --git a/src/terminal/kitty/dnd_command.zig b/src/terminal/kitty/dnd_command.zig index f056f1406..b5f63d02b 100644 --- a/src/terminal/kitty/dnd_command.zig +++ b/src/terminal/kitty/dnd_command.zig @@ -240,7 +240,8 @@ pub const Request = union(enum) { } }; -/// Chunk reassembly state, mirroring kitty's per-screen dnd_chunking. +/// Chunk reassembly state. +/// /// While a chunked command is in progress, the metadata of the first /// chunk is reused for all subsequent chunks; only the `more` flag is /// taken from each continuation. diff --git a/src/terminal/kitty/dnd_drop.zig b/src/terminal/kitty/dnd_drop.zig new file mode 100644 index 000000000..f9486f9e9 --- /dev/null +++ b/src/terminal/kitty/dnd_drop.zig @@ -0,0 +1,715 @@ +//! Kitty drag and drop protocol (OSC 72) state machine. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const assert = @import("../../quirks.zig").inlineAssert; +const osc = @import("../osc.zig"); +const command = @import("dnd_command.zig"); +const response = @import("dnd_response.zig"); + +const Metadata = command.Metadata; +const Operation = command.Operation; +const Operations = command.Operations; + +const log = std.log.scoped(.kitty_dnd); + +/// Maximum accumulated size of a client-sent MIME list (the accepted +/// list of a `t=m` status update). Matches kitty's MIME_LIST_SIZE_CAP. +pub const max_mime_list_bytes = 1024 * 1024; + +/// Process one OSC 72 command received from the client, writing any +/// responses to the writer. Returns the state change the embedder may +/// need to act on, if any. +pub fn handleCommand( + slot: *?*State, + alloc: Allocator, + writer: *std.Io.Writer, + v: osc.Command.KittyDndProtocol, +) (Allocator.Error || std.Io.Writer.Error)!?Event { + const raw = Metadata.parse(v.metadata) orelse { + log.debug("dropping malformed OSC 72 metadata", .{}); + return null; + }; + + // Chunk reassembly lives in the state, so before registration + // each command stands alone. The only legitimately chunked + // command before registration is t=a itself, which seeds the + // reassembly on its first chunk below. + const continuation = if (slot.*) |state| state.chunking.active else false; + const meta = if (slot.*) |state| state.chunking.apply(raw) else raw; + const payload = v.payload orelse ""; + const t = meta.type orelse return null; + + switch (t) { + .register => { + // x=1 declares the client's machine ID for remote drop + // support. We don't support remote drop yet, so accept and ignore. + if (meta.cell_x == 1) return null; + + // Setup our state if we haven't already + const state = slot.* orelse state: { + const state = try State.create(alloc); + slot.* = state; + _ = state.chunking.apply(raw); + break :state state; + }; + + // Update the client ID on every registration + state.drop.client_id = meta.client_id; + + return try state.register( + alloc, + payload, + continuation, + meta.more, + ); + }, + + .unregister => { + const state = slot.* orelse return null; + state.destroy(alloc); + slot.* = null; + return .registration; + }, + + .status => { + const state = slot.* orelse return null; + return try state.acceptStatus(alloc, meta, payload); + }, + + .request => return try dataRequest( + slot.*, + alloc, + writer, + meta, + v.terminator, + ), + + // Drag source control. Enabling (x=1, with an optional + // machine ID payload) and disabling (x=2) offers are + // accepted and ignored since the terminal never requests a + // drag start. Offering a MIME list (x=0) for a new drag is + // refused since drag-out is not implemented. + .offer => if (meta.cell_x == 0) try refuseDragOut( + writer, + meta, + v.terminator, + ), + + // Drag-out data and start commands. A conforming client + // never sends these because the terminal never requests a + // drag start, but refuse them properly if one does. + .present, .start_drag => try refuseDragOut( + writer, + meta, + v.terminator, + ), + + // Responses to drag-out requests the terminal never makes. + .drag_event, .drag_error, .remote_data => {}, + + .query => try response.encode( + writer, + "t=q", + meta.client_id, + "", + .plain, + v.terminator, + ), + + // Only ever sent by the terminal. Ignore. + .drop, .request_error => {}, + } + + return null; +} + +/// Handle a t=r data request or drop conclusion from the client. +/// Requests from an unregistered client (no state) get the same +/// errors kitty sends from its zeroed drop state. +fn dataRequest( + state: ?*State, + alloc: Allocator, + writer: *std.Io.Writer, + meta: Metadata, + terminator: osc.Terminator, +) (Allocator.Error || std.Io.Writer.Error)!?Event { + // Responses echo the registration's client ID, matching kitty. + const client_id = if (state) |s| s.drop.client_id else 0; + + switch (command.Request.init(meta)) { + .conclude => |op| { + // The client is done with the drop: free the held data and + // report the operation it performed. Kitty hands that to + // the still-open OS drag session; ours ended at drop time + // (see dnd.zig), so the embedder decides what to do with + // it. A conclusion with no drop in progress is a no-op. + const s = state orelse return null; + const dropped = s.drop.dropped; + s.resetDrop(alloc); + return if (dropped) Event.concluded(op) else null; + }, + + .mime => |idx| { + const keys: response.RequestKeys = .{ .x = idx }; + const items = (if (state) |s| s.drop.items else null) orelse { + try response.encodeError( + writer, + .drop, + keys, + client_id, + .ENOENT, + "no drop data available", + terminator, + ); + return null; + }; + if (idx < 1 or @as(usize, @intCast(idx)) > items.len) { + try response.encodeError( + writer, + .drop, + keys, + client_id, + .ENOENT, + "drop data request index out of bounds", + terminator, + ); + return null; + } + + var header_buf: [32]u8 = undefined; + const header = std.fmt.bufPrint( + &header_buf, + "t=r{f}", + .{keys}, + ) catch unreachable; + + // The data chunks followed by the empty end-of-data + // message, which is how the client detects completion. + // An empty item is just the end-of-data message alone; + // clients treat a duplicate as a second completion. + const item = items[@intCast(idx - 1)]; + if (item.data.len > 0) try response.encode( + writer, + header, + client_id, + item.data, + .base64, + terminator, + ); + try response.encode(writer, header, client_id, "", .base64, terminator); + return null; + }, + + // Remote drop transfers (URI file contents and directory + // handles). We never advertise remote support (no X=1 + // marker), so a conforming client never sends these. + .uri => |uri| try response.encodeError( + writer, + .drop, + .{ .x = uri.mime_idx, .y = uri.uri_idx }, + client_id, + .EINVAL, + "remote drop data is not supported", + terminator, + ), + .dir => |dir| try response.encodeError( + writer, + .drop, + .{ .x = dir.entry, .Y = dir.handle }, + client_id, + .EINVAL, + "remote drop data is not supported", + terminator, + ), + } + + return null; +} + +/// Refuse a drag-out command with an error, since ghostty does not +/// implement the terminal side of client-initiated drags yet. +fn refuseDragOut( + writer: *std.Io.Writer, + meta: Metadata, + terminator: osc.Terminator, +) std.Io.Writer.Error!void { + try response.encodeError( + writer, + .drag, + .{}, + meta.client_id, + .EPERM, + "drag out is not supported by this terminal", + terminator, + ); +} + +/// A protocol state change an embedder may need to act on, returned by +/// `handleCommand` and delivered through the stream handler's +/// `dnd_event` effect. This is a flat enum so it can cross a C API +/// unchanged; any details are read back from `Terminal.kitty_dnd`. +pub const Event = enum { + /// The client registered (t=a), re-registered, or unregistered + /// (t=A) to accept drops. An embedder may want to use this + /// to setup the proper mime types to accept (e.g. on macOS) + /// or not (unregistered). + registration, + + /// The client answered the drag currently over the terminal. + /// `State.clientAccepted` has the answer. Embedders can refresh the + /// OS drag feedback immediately rather than on the next move. + acceptance, + + /// The client concluded a drop, performing no operation (it + /// canceled), a copy, or a move. The held drop data has been freed. + concluded_none, + concluded_copy, + concluded_move, + + /// The conclusion event for a performed operation. + pub fn concluded(op: Operation) Event { + return switch (op) { + .none => .concluded_none, + .copy => .concluded_copy, + .move => .concluded_move, + }; + } +}; + +/// The per-terminal drop target state. +/// +/// The primary entrypoint is `handleCommand` which takes a `*?*State` +/// slot that it can heap allocate into when DnD activates and free when +/// it deactivates. +/// +/// The normal lifecycle: +/// +/// 1. The stream handler feeds every OSC 72 command received from the +/// client to `handleCommand`. The client registers (t=a), which +/// allocates the state into the slot and yields a `registration` +/// event so the embedder can register any declared MIME types +/// with the OS. Until then `handleCommand` only answers stateless +/// commands (queries, error responses). +/// 2. A native drag enters or moves over the terminal. When the slot +/// is non-null, the embedder calls `dragMove` with the pointer +/// position, the operations the drag source allows, and the MIME +/// types it can serve if dropped. This sends the client a t=m +/// move event; when the slot is null the embedder should handle +/// the drag as it would without the protocol. +/// 3. The client answers with its acceptance (t=m:o=N), recorded by +/// `handleCommand` which yields an `acceptance` event. The +/// embedder reads `clientAccepted` then and on subsequent moves +/// to give the OS drag session its feedback. +/// 4. The drag either leaves, and the embedder calls `dragLeave` to +/// send the t=m leave event, or drops: the embedder captures the +/// representations it advertised and calls `dragDrop`, which +/// copies and holds them and sends the client a t=M drop event. A +/// new drag entering before the client concludes discards the +/// held drop. +/// 5. The client requests data (t=r:x=N), which `handleCommand` +/// serves from the held copies, and then concludes the drop +/// (t=r:o=N), which frees them and yields a `concluded_*` event +/// naming the operation the client performed. +/// 6. The client unregisters (t=A) and `handleCommand` frees the +/// state, yielding a final `registration` event, or the terminal +/// is deinitialized and calls `destroy`. +/// +/// All calls must use the allocator the state was created with (the +/// terminal's) and require the same synchronization as any other +/// terminal mutation. +pub const State = struct { + /// Chunk reassembly for client commands. This is the only part of + /// the state cleared by a terminal reset (RIS), matching kitty. + chunking: command.Chunking = .{}, + + /// Drop target state for the registered client. + drop: DropTarget = .{}, + + pub const DropTarget = struct { + /// Multiplexer client ID from registration, echoed in every + /// drop-side message the terminal sends. + client_id: u32 = 0, + + /// The MIME list the client registered with (the t=a payload), + /// space-separated as received and accumulated across chunks. + /// Only needed by embedders that must register types with the + /// OS ahead of a drag; kitty frees it after doing so, we keep + /// it so the `registration` event can be acted on from here. + registered_mimes: std.ArrayListUnmanaged(u8) = .empty, + + /// True while the pointer of a native drag is over the terminal. + hovered: bool = false, + + /// True after the native drop until the client concludes it. + dropped: bool = false, + + /// The client's response to the current drag, null until the + /// client has responded. `none` means the client rejected it. + accepted: ?Operation = null, + + /// True while a chunked t=m acceptance is being accumulated. + accept_in_progress: bool = false, + + /// The client's accepted MIME list: space-separated while + /// accumulating, converted to NUL-separated (with a trailing + /// NUL) once complete, matching kitty's in-place conversion. + accepted_mimes: std.ArrayListUnmanaged(u8) = .empty, + + /// The MIME types of the current native drag, in the order + /// that data request indices refer to. + offered: ?Offered = null, + + /// The data captured at drop time, parallel to `offered`. + items: ?[]const Item = null, + }; + + /// One dropped representation: a MIME type and its data. + pub const Item = struct { + mime: []const u8, + data: []const u8, + }; + + /// The MIME list of the current drag plus the pre-joined move-event + /// payload ("mime1 mime2 " with a trailing space after every entry, + /// matching kitty) so per-move encoding is allocation-free. + const Offered = struct { + mimes: []const []const u8, + payload: []const u8, + + fn init(alloc: Allocator, mimes: []const []const u8) Allocator.Error!Offered { + const copies = try alloc.alloc([]const u8, mimes.len); + errdefer alloc.free(copies); + + var payload_len: usize = 0; + for (mimes) |m| payload_len += m.len + 1; + + const payload = try alloc.alloc(u8, payload_len); + errdefer alloc.free(payload); + + var offset: usize = 0; + for (mimes, copies) |m, *copy| { + @memcpy(payload[offset..][0..m.len], m); + payload[offset + m.len] = ' '; + copy.* = payload[offset..][0..m.len]; + offset += m.len + 1; + } + + return .{ .mimes = copies, .payload = payload }; + } + + fn deinit(self: *const Offered, alloc: Allocator) void { + alloc.free(self.mimes); + alloc.free(self.payload); + } + + fn eql(self: *const Offered, mimes: []const []const u8) bool { + if (self.mimes.len != mimes.len) return false; + for (self.mimes, mimes) |a, b| { + if (!std.mem.eql(u8, a, b)) return false; + } + return true; + } + }; + + /// The maximum number of dropped items. Embedders provide a small + /// curated set of representations (see dnd.zig), so this is a + /// generous bound that keeps the MIME list assembly on the stack. + pub const max_items = 16; + + /// Allocate a fresh state. Done by `handleCommand` on registration. + fn create(alloc: Allocator) Allocator.Error!*State { + const state = try alloc.create(State); + state.* = .{}; + return state; + } + + /// Free the state and everything it holds. + pub fn destroy(self: *State, alloc: Allocator) void { + self.deinit(alloc); + alloc.destroy(self); + } + + fn deinit(self: *State, alloc: Allocator) void { + self.freeDragData(alloc); + self.drop.accepted_mimes.deinit(alloc); + self.drop.registered_mimes.deinit(alloc); + } + + /// Iterate the MIME types the client registered with, in order. + /// Empty when the client declared none, which is the common case. + /// The list is only needed to register exotic types with the OS, + /// such as macOS pasteboard stuff. + pub fn registeredMimes(self: *const State) std.mem.TokenIterator(u8, .scalar) { + return std.mem.tokenizeScalar( + u8, + self.drop.registered_mimes.items, + ' ', + ); + } + + /// Record one chunk of a registration's MIME list. + /// + /// `continuation` is true for every chunk but the first of a chunked + /// registration. Returns the registration event once the list is complete. + fn register( + self: *State, + alloc: Allocator, + payload: []const u8, + continuation: bool, + more: bool, + ) Allocator.Error!?Event { + const list = &self.drop.registered_mimes; + if (!continuation) list.clearRetainingCapacity(); + + // Matching kitty, an over-cap chunk is dropped and does not + // complete the registration. + if (list.items.len + payload.len > max_mime_list_bytes) return null; + try list.appendSlice(alloc, payload); + + return if (more) null else .registration; + } + + /// The client's acceptance response for the drag currently over the + /// terminal, for OS drag feedback. Null when the client hasn't + /// responded yet (embedders should fall back to their default, + /// typically copy) or `none` when the client rejected the drag. + pub fn clientAccepted(self: *const State) ?Operation { + if (self.drop.accept_in_progress) return null; + return self.drop.accepted; + } + + /// Free the per-drag data (offered MIME list and held drop items). + fn freeDragData(self: *State, alloc: Allocator) void { + if (self.drop.offered) |*offered| { + offered.deinit(alloc); + self.drop.offered = null; + } + if (self.drop.items) |items| { + for (items) |item| { + alloc.free(item.mime); + alloc.free(item.data); + } + alloc.free(items); + self.drop.items = null; + } + } + + /// Clear the per-drag state while preserving the registration, + /// mirroring kitty's reset_drop. Called when a new drag enters and + /// when a drop concludes. + fn resetDrop(self: *State, alloc: Allocator) void { + self.freeDragData(alloc); + self.drop.accepted_mimes.clearAndFree(alloc); + self.drop.hovered = false; + self.drop.dropped = false; + self.drop.accepted = null; + self.drop.accept_in_progress = false; + } + + /// Handle a t=m acceptance status update from the client, mirroring + /// kitty's drop_set_status. + fn acceptStatus( + self: *State, + alloc: Allocator, + meta: Metadata, + payload: []const u8, + ) Allocator.Error!?Event { + const d = &self.drop; + if (!d.accept_in_progress) { + d.accepted_mimes.clearRetainingCapacity(); + d.accept_in_progress = true; + d.accepted = .fromProtocol(meta.operation); + } + + if (payload.len > 0) { + // Matching kitty, an over-cap list stops accumulating and + // never finalizes, leaving the acceptance unanswered. + if (d.accepted_mimes.items.len + payload.len > max_mime_list_bytes) return null; + try d.accepted_mimes.appendSlice(alloc, payload); + } + + if (meta.more) return null; + d.accept_in_progress = false; + if (d.accepted_mimes.items.len > 0) { + for (d.accepted_mimes.items) |*c| { + if (c.* == ' ') c.* = 0; + } + try d.accepted_mimes.append(alloc, 0); + } + return .acceptance; + } + + /// A native drag position report from the embedder. + pub const MoveEvent = struct { + /// Grid cell under the pointer, zero-based from the top-left. + cell_x: u32, + cell_y: u32, + + /// Pointer position in pixels relative to the top-left of the + /// terminal's content area. + pixel_x: i32, + pixel_y: i32, + + /// The operations the drag source allows. + operations: Operations, + }; + + /// Report a native drag moving over the terminal, sending a t=m + /// move event to the client. `mimes` is the list of MIME types the + /// terminal can provide for this drag, in the order data request + /// indices will refer to. + pub fn dragMove( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + mimes: []const []const u8, + ) (Allocator.Error || std.Io.Writer.Error)!void { + try self.moveEvent( + alloc, + writer, + ev, + mimes, + false, + ); + } + + /// Report a native drop onto the terminal. The items' data is + /// copied and held so the client's data requests can be served; it + /// is freed when the client concludes the drop, a new drag enters, + /// or the client unregisters. + /// + /// Sends a t=M drop event listing the items' MIME types. + pub fn dragDrop( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + items: []const Item, + ) (Allocator.Error || std.Io.Writer.Error)!void { + // Copy the items so they can be served after this call returns. + // Items beyond the cap are dropped so the held list always + // matches the advertised MIME list. + const accepted_items = items[0..@min(items.len, max_items)]; + const copies = try alloc.alloc(Item, accepted_items.len); + errdefer alloc.free(copies); + var copied: usize = 0; + errdefer for (copies[0..copied]) |item| { + alloc.free(item.mime); + alloc.free(item.data); + }; + for (accepted_items, copies) |item, *copy| { + const mime = try alloc.dupe(u8, item.mime); + errdefer alloc.free(mime); + const data = try alloc.dupe(u8, item.data); + copy.* = .{ .mime = mime, .data = data }; + copied += 1; + } + + // The move handling below resets per-drag state when this drop + // arrives without a preceding move, so the items are attached + // after it runs. Collect the MIME list first. + var mimes_buf: [max_items][]const u8 = undefined; + const mimes = mimes_buf[0..copies.len]; + for (mimes, copies) |*m, item| m.* = item.mime; + + try self.moveEvent( + alloc, + writer, + ev, + mimes, + true, + ); + + assert(self.drop.items == null); + self.drop.items = copies; + } + + /// Report the native drag leaving the terminal, sending the t=m + /// leave event (x=-1, y=-1). + /// + /// Ignored after a drop: some toolkits emit a leave notification + /// for the drop itself, and the held data must survive until the + /// client concludes. + pub fn dragLeave( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (self.drop.dropped) return; + const hovered = self.drop.hovered; + self.drop.hovered = false; + if (self.drop.offered) |*offered| { + offered.deinit(alloc); + self.drop.offered = null; + } + + // Only a client that saw the drag enter gets the leave event, + // matching kitty which notifies hovered windows only. + if (!hovered) return; + + try response.encode( + writer, + "t=m:x=-1:y=-1", + self.drop.client_id, + "", + .plain, + .st, + ); + } + + /// Shared implementation of move and drop events, mirroring kitty's + /// drop_move_on_child. + fn moveEvent( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + mimes: []const []const u8, + is_drop: bool, + ) (Allocator.Error || std.Io.Writer.Error)!void { + if (!self.drop.hovered) { + self.resetDrop(alloc); + self.drop.hovered = true; + } + if (is_drop) { + self.drop.dropped = true; + self.drop.hovered = false; + } + + // (Re)build the offered MIME list when it changed. + if (self.drop.offered == null or !self.drop.offered.?.eql(mimes)) { + if (self.drop.offered) |*offered| offered.deinit(alloc); + self.drop.offered = null; + self.drop.offered = try Offered.init(alloc, mimes); + } + + var header_buf: [96]u8 = undefined; + const header = std.fmt.bufPrint( + &header_buf, + "t={c}:x={d}:y={d}:X={d}:Y={d}:o={d}", + .{ + @as(u8, if (is_drop) 'M' else 'm'), + ev.cell_x, + ev.cell_y, + ev.pixel_x, + ev.pixel_y, + ev.operations.protocolValue(), + }, + ) catch unreachable; + + // The MIME list is sent with every move event, matching kitty + // (the spec suggests only the first, but kitty always sends it + // and clients depend on that). + try response.encode( + writer, + header, + self.drop.client_id, + self.drop.offered.?.payload, + .plain, + .st, + ); + } +}; diff --git a/src/terminal/kitty/dnd_test.zig b/src/terminal/kitty/dnd_test.zig new file mode 100644 index 000000000..c78ce00f6 --- /dev/null +++ b/src/terminal/kitty/dnd_test.zig @@ -0,0 +1,626 @@ +//! End-to-end tests for the OSC 72 protocol state machine, validating +//! wire behavior against kitty's implementation (using kitty_tests/dnd.py as +//! an oracle for the expected bytes). +//! +//! It isn't normal for us to have dedicated test files but in this case +//! the dnd protocol is complicated enough that I wanted full e2e covering +//! the full machine. + +const std = @import("std"); +const testing = std.testing; + +const osc = @import("../osc.zig"); +const dnd = @import("dnd.zig"); + +/// A test harness holding the lazily allocated protocol state and an +/// output collector. +const Harness = struct { + state: ?*dnd.State = null, + output: std.Io.Writer.Allocating, + + fn init() Harness { + return .{ .output = .init(testing.allocator) }; + } + + fn deinit(self: *Harness) void { + if (self.state) |state| state.destroy(testing.allocator); + self.output.deinit(); + } + + /// The registered state; asserts a client has registered. + fn registered(self: *Harness) *dnd.State { + return self.state.?; + } + + /// Feed one client command, as it would arrive from the OSC parser, + /// returning the event the stream handler would pass to its effect. + fn command(self: *Harness, metadata: []const u8, payload: ?[]const u8) !?dnd.Event { + return try dnd.handleCommand(&self.state, testing.allocator, &self.output.writer, .{ + .metadata = metadata, + .payload = payload, + .terminator = .st, + }); + } + + /// Consume and return the collected output. + fn consume(self: *Harness) []const u8 { + const written = self.output.written(); + return written; + } + + fn clear(self: *Harness) void { + self.output.clearRetainingCapacity(); + } + + fn expectOutput(self: *Harness, expected: []const u8) !void { + try testing.expectEqualStrings(expected, self.output.written()); + self.clear(); + } +}; + +test "dnd: query response" { + var h: Harness = .init(); + defer h.deinit(); + + // Works without any registration, matching kitty, and allocates + // nothing. + _ = try h.command("t=q", null); + try h.expectOutput("\x1b]72;t=q\x1b\\"); + try testing.expect(h.state == null); +} + +test "dnd: query response echoes client id" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=q:i=31", null); + try h.expectOutput("\x1b]72;t=q:i=31\x1b\\"); +} + +test "dnd: register and unregister" { + var h: Harness = .init(); + defer h.deinit(); + + try testing.expect(h.state == null); + + // Registration allocates the state and reports it, with the + // declared MIME list readable from the state. + try testing.expect((try h.command("t=a", "text/plain text/uri-list")).? == .registration); + try h.expectOutput(""); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("text/plain", it.next().?); + try testing.expectEqualStrings("text/uri-list", it.next().?); + try testing.expect(it.next() == null); + } + + // Machine ID declaration is accepted and ignored. + try testing.expect((try h.command("t=a:x=1", "1:deadbeef")) == null); + try h.expectOutput(""); + try testing.expect(h.state != null); + + // Re-registration replaces the list. + try testing.expect((try h.command("t=a", "image/png")).? == .registration); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("image/png", it.next().?); + try testing.expect(it.next() == null); + } + + // Registering without a list is the common case. + try testing.expect((try h.command("t=a", null)).? == .registration); + { + var it = h.registered().registeredMimes(); + try testing.expect(it.next() == null); + } + + // Unregistration frees it and reports the change. + try testing.expect((try h.command("t=A", null)).? == .registration); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Unregistering again changes nothing. + try testing.expect((try h.command("t=A", null)) == null); + try h.expectOutput(""); + try testing.expect(h.state == null); +} + +test "dnd: no state before registration" { + var h: Harness = .init(); + defer h.deinit(); + + // State-dependent commands from an unregistered client allocate + // nothing; a data request gets the error kitty sends from its + // zeroed state. + _ = try h.command("t=m:o=1", "text/plain"); + _ = try h.command("t=r", null); + try h.expectOutput(""); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); + try testing.expect(h.state == null); +} + +test "dnd: move event carries position, operations, and mime list" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", "text/plain"); + + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 5, + .cell_y = 3, + .pixel_x = 100, + .pixel_y = 60, + .operations = .{ .copy = true }, + }, &.{ "text/plain", "text/uri-list" }); + + // Note the trailing space after every MIME entry, matching kitty. + try h.expectOutput( + "\x1b]72;t=m:x=5:y=3:X=100:Y=60:o=1:m=0;text/plain text/uri-list \x1b\\", + ); +} + +test "dnd: move event echoes registration client id" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:i=7", ""); + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 1, + .cell_y = 2, + .pixel_x = 8, + .pixel_y = 16, + .operations = .{ .copy = true, .move = true }, + }, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=1:y=2:X=8:Y=16:o=3:i=7:m=0;text/plain \x1b\\"); +} + +test "dnd: re-registration updates client id in place" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:i=7", ""); + const state = h.registered(); + _ = try h.command("t=a:i=9", ""); + // Same allocation, new client ID. + try testing.expect(h.state.? == state); + try testing.expectEqual(@as(u32, 9), state.drop.client_id); +} + +test "dnd: mime list sent on every move" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + const ev: dnd.MoveEvent = .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }; + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + h.clear(); + + // Kitty resends the list even when unchanged; clients depend on it. + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=0:y=0:X=0:Y=0:o=1:m=0;text/plain \x1b\\"); +} + +test "dnd: leave event" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{"text/plain"}); + h.clear(); + + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput("\x1b]72;t=m:x=-1:y=-1\x1b\\"); +} + +test "dnd: client acceptance recorded" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try testing.expect(h.registered().clientAccepted() == null); + + try testing.expect((try h.command("t=m:o=1", "text/plain")).? == .acceptance); + try h.expectOutput(""); + try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?); + + // Rejection. + try testing.expect((try h.command("t=m:o=0", "")).? == .acceptance); + try testing.expectEqual(dnd.Operation.none, h.registered().clientAccepted().?); +} + +test "dnd: chunked client acceptance" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // Chunked accept: continuation metadata is ignored, the acceptance + // is pending until the final chunk. + try testing.expect((try h.command("t=m:o=2:m=1", "text/pl")) == null); + try testing.expect(h.registered().clientAccepted() == null); + try testing.expect((try h.command("t=m:m=1", "ain text")) == null); + try testing.expect((try h.command("t=m:m=0", "/html")).? == .acceptance); + try testing.expectEqual(dnd.Operation.move, h.registered().clientAccepted().?); + + // The accumulated list was converted to NUL-separated entries. + try testing.expectEqualSlices( + u8, + "text/plain\x00text/html\x00", + h.registered().drop.accepted_mimes.items, + ); +} + +test "dnd: drop and data serving round trip" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", "text/plain text/uri-list"); + + const ev: dnd.MoveEvent = .{ + .cell_x = 4, + .cell_y = 2, + .pixel_x = 40, + .pixel_y = 20, + .operations = .{ .copy = true }, + }; + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/uri-list", .data = "file:///tmp/a.txt\r\n" }, + .{ .mime = "text/plain", .data = "hello" }, + }); + try h.expectOutput( + "\x1b]72;t=M:x=4:y=2:X=40:Y=20:o=1:m=0;text/uri-list text/plain \x1b\\", + ); + + // Request the second MIME's data: base64 chunk plus the empty + // end-of-data message. + _ = try h.command("t=r:x=2", null); + try h.expectOutput( + "\x1b]72;t=r:x=2:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=2\x1b\\", + ); + + // Out-of-bounds request. + _ = try h.command("t=r:x=3", null); + try h.expectOutput( + "\x1b]72;t=R:x=3:m=0;ENOENT:drop data request index out of bounds\x1b\\", + ); + + // Conclude: the performed operation is reported, held data is + // freed, and further requests fail. + try testing.expectEqual(dnd.Event.concluded_copy, (try h.command("t=r:o=1", null)).?); + try h.expectOutput(""); + try testing.expect((try h.command("t=r:o=1", null)) == null); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: empty item served as a single end-of-data message" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "" }}); + h.clear(); + + // Kitty's oracle (test_empty_data) asserts exactly one message: + // the empty response is itself the end-of-data signal, and a + // duplicate would be a second completion to the client. + _ = try h.command("t=r:x=1", null); + try h.expectOutput("\x1b]72;t=r:x=1\x1b\\"); +} + +test "dnd: leave without hover sends nothing" { + var h: Harness = .init(); + defer h.deinit(); + + // Client registered but no move was ever forwarded (e.g. it + // registered mid-drag): kitty only notifies hovered windows. + _ = try h.command("t=a", ""); + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput(""); +} + +test "dnd: data request with no drop" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: leave after drop is ignored" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + h.clear(); + + // Some toolkits emit a leave for the drop itself; the held data + // must survive so the client can still fetch it. + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput(""); + + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=r:x=1:m=0;eA==\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\", + ); +} + +test "dnd: new drag resets held drop data" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + const ev: dnd.MoveEvent = .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }; + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/plain", .data = "old" }, + }); + h.clear(); + + // A new drag entering resets the per-drag state including the held + // items from the unconcluded previous drop. + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + h.clear(); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: remote transfer requests refused" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // URI file content request. + _ = try h.command("t=r:x=1:y=2", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:y=2:m=0;EINVAL:remote drop data is not supported\x1b\\", + ); + + // Directory handle request. + _ = try h.command("t=r:Y=2:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:Y=2:m=0;EINVAL:remote drop data is not supported\x1b\\", + ); +} + +test "dnd: drag out refused" { + var h: Harness = .init(); + defer h.deinit(); + + // Enabling and disabling offers is accepted silently and allocates + // nothing. + _ = try h.command("t=o:x=1", null); + _ = try h.command("t=o:x=2", null); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Offering a drag is refused. + _ = try h.command("t=o:x=1", null); + _ = try h.command("t=o:o=3", "text/plain"); + try h.expectOutput( + "\x1b]72;t=E:m=0;EPERM:drag out is not supported by this terminal\x1b\\", + ); + + // Starting a drag is refused, echoing the command's client id. + _ = try h.command("t=P:x=-1:i=9", null); + try h.expectOutput( + "\x1b]72;t=E:i=9:m=0;EPERM:drag out is not supported by this terminal\x1b\\", + ); + try testing.expect(h.state == null); +} + +test "dnd: unregister frees held drop data" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + h.clear(); + + // The testing allocator would report the held data as leaked if + // unregistration didn't free the whole state. + _ = try h.command("t=A", null); + try testing.expect(h.state == null); +} + +test "dnd: chunked registration reuses first chunk metadata" { + var h: Harness = .init(); + defer h.deinit(); + + // Registration split over two chunks: the first chunk allocates + // the state and seeds chunk reassembly, so the continuation (which + // carries a different type) is still treated as the registration. + try testing.expect((try h.command("t=a:i=4:m=1", "text/pla")) == null); + try testing.expect(h.state != null); + try testing.expect((try h.command("t=q:m=0", "in")).? == .registration); + try h.expectOutput(""); + try testing.expectEqual(@as(u32, 4), h.registered().drop.client_id); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("text/plain", it.next().?); + } + + // A query after the chunked command completes works again. + _ = try h.command("t=q", null); + try h.expectOutput("\x1b]72;t=q\x1b\\"); +} + +test "dnd: malformed metadata ignored" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:zz=1", ""); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Command with no type is ignored, matching kitty (the spec's + // default of t=a is not honored by the reference implementation). + _ = try h.command("x=1", ""); + try h.expectOutput(""); + try testing.expect(h.state == null); +} + +test "dnd: bel terminator echoed in responses" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try dnd.handleCommand(&h.state, testing.allocator, &h.output.writer, .{ + .metadata = "t=q", + .payload = null, + .terminator = .bel, + }); + try h.expectOutput("\x1b]72;t=q\x07"); +} + +test "dnd: kitten 0.47 conversation replay" { + // This replays a conversation recorded from the reference client + // (`kitten dnd --drop-anywhere=copy --drop text/plain:out.txt`, + // kitten 0.47.0) driven over a pty by a harness that sent exactly + // the bytes this engine produces. The kitten accepted the events, + // wrote the dropped payload to disk intact, and concluded; its + // client bytes are frozen here as an interop regression test. + var h: Harness = .init(); + defer h.deinit(); + + // Startup: register with MIME list and machine ID, then the test + // harness reset (unregister both directions, re-register). + _ = try h.command("t=a:m=0", "text/uri-list text/plain"); + _ = try h.command( + "t=a:x=1:m=0", + "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd", + ); + _ = try h.command("t=A", null); + try testing.expect(h.state == null); + _ = try h.command("t=o:x=2", null); + _ = try h.command("t=a:m=0", "text/uri-list text/plain"); + _ = try h.command( + "t=a:x=1:m=0", + "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd", + ); + try h.expectOutput(""); + try testing.expect(h.state != null); + + // Native drag moves over the terminal and drops. + const ev: dnd.MoveEvent = .{ + .cell_x = 2, + .cell_y = 1, + .pixel_x = 20, + .pixel_y = 18, + .operations = .{ .copy = true }, + }; + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\"); + + // The kitten accepts as a copy of text/plain. + _ = try h.command("t=m:o=1:m=0", "text/plain"); + try h.expectOutput(""); + try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?); + + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/plain", .data = "hello from ghostty\n" }, + }); + try h.expectOutput("\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\"); + + // The kitten requests the data and concludes with a copy. + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=r:x=1:m=0;aGVsbG8gZnJvbSBnaG9zdHR5Cg==\x1b\\" ++ + "\x1b]72;t=r:x=1\x1b\\", + ); + _ = try h.command("t=r:o=1", null); + try h.expectOutput(""); + try testing.expect(h.registered().drop.items == null); +} + +test "dnd: large data served in chunks" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // 3073 bytes: one full chunk plus one byte. + const data = [_]u8{'Z'} ** 3073; + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "application/octet-stream", .data = &data }}); + h.clear(); + + _ = try h.command("t=r:x=1", null); + const out = h.consume(); + + // First chunk is m=1 with 4096 base64 chars, second is m=0, and + // the final message is the bare end-of-data marker. + try testing.expect(std.mem.startsWith(u8, out, "\x1b]72;t=r:x=1:m=1;")); + try testing.expect(std.mem.indexOf(u8, out, "\x1b]72;t=r:x=1:m=0;") != null); + try testing.expect(std.mem.endsWith(u8, out, "\x1b]72;t=r:x=1\x1b\\")); + h.clear(); +} + +test "dnd: over-cap registration list never completes" { + var h: Harness = .init(); + defer h.deinit(); + + // Matching kitty, a chunk that would exceed the cap is dropped and + // the registration is not reported, though the client stays + // registered (the state exists). + const big = try testing.allocator.alloc(u8, dnd.max_mime_list_bytes + 1); + defer testing.allocator.free(big); + @memset(big, 'a'); + try testing.expect((try h.command("t=a", big)) == null); + try testing.expect(h.state != null); + { + var it = h.registered().registeredMimes(); + try testing.expect(it.next() == null); + } +} diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig index 35d239066..839e39618 100644 --- a/src/terminal/osc/parsers/kitty_dnd_protocol.zig +++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -1,5 +1,12 @@ //! Kitty's drag and drop protocol (OSC 72) -//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ +//! +//! This only captures the raw metadata and payload for the OSC. The +//! actual protocol grammar (metadata keys, event types, chunking) is +//! implemented in `terminal/kitty/dnd.zig` and its submodules, since the +//! protocol requires stateful handling that doesn't belong in the +//! stateless OSC parser. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/ const std = @import("std"); @@ -9,143 +16,52 @@ const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const log = std.log.scoped(.kitty_dnd_protocol); - pub const OSC = struct { - /// The raw metadata that was received. Parse individual values with `readOption`. + /// The raw metadata that was received. Parse with + /// `kitty.dnd.Metadata.parse`. metadata: []const u8, - /// The raw payload. Its meaning and encoding depend on the event type (`t` key). + + /// The raw payload. Its meaning and encoding depend on the event + /// type (`t` metadata key). Null when the OSC had no `;` after the + /// metadata; an empty payload is distinct from no payload. payload: ?[]const u8, + /// The terminator used for this OSC, so any response can match it. terminator: Terminator, - pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { - return key.read(self.metadata); + /// We don't currently support encoding this to C in any way. + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; } }; -/// Values for the `t` (event type) metadata key. -pub const EventType = enum { - /// ('a') Terminal registers itself as willing to accept drops. - accept_drops, - /// ('A') Terminal unregisters itself; drops should no longer be forwarded. - stop_accepting_drops, - /// ('m') Pointer is moving over the terminal while a drag is in progress. - /// Carries `x`/`y` cursor position; -1 signals the drag left the window. - drop_move, - /// ('M') Items were dropped onto the terminal. - /// Carries `x`/`y` drop position and `i` (multiplexer session ID). - drop_dropped, - /// ('r') Terminal requests data for a specific MIME type from the drag source. - /// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index). - request_data, - /// ('R') Error response to a `request_data` event. - request_error, - /// ('o') Terminal offers data for an outgoing drag (drag-out from terminal). - offer_drag, - /// ('p') Drag source presents the actual payload for a previously requested MIME type. - /// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag). - present_data, - /// ('P') Replace the current drag image with a new one. - /// Payload is the image data; `X`/`Y` carry image dimensions in pixels. - change_drag_image, - /// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected). - drag_offer_event, - /// ('E') Error on an outgoing drag offer. - drag_offer_error, - /// ('k') URI list data delivered as part of a drag or clipboard transfer. - uri_list_data, - /// ('q') Query terminal capabilities related to the drag-and-drop protocol. - query, +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + assert(parser.state == .@"72"); - pub fn init(str: []const u8) ?EventType { - if (str.len != 1) return null; - return switch (str[0]) { - 'a' => .accept_drops, - 'A' => .stop_accepting_drops, - 'm' => .drop_move, - 'M' => .drop_dropped, - 'r' => .request_data, - 'R' => .request_error, - 'o' => .offer_drag, - 'p' => .present_data, - 'P' => .change_drag_image, - 'e' => .drag_offer_event, - 'E' => .drag_offer_error, - 'k' => .uri_list_data, - 'q' => .query, - else => null, - }; - } -}; + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; -/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct. -pub const Option = enum { - /// Event type. Maps to `EventType`; present in every OSC 72 sequence. - t, - /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow. - m, - /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer - /// (e.g. tmux) can route data to the correct pane. - i, - /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move. - o, - /// Cursor column in cell units (zero-based). -1 signals the drag has left the window. - x, - /// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index - /// in some events (e.g. `request_data`). -1 signals the drag has left the window. - y, - /// Pixel offset from the left edge of the cell; also used as image width - /// (with `change_drag_image`) or as a symlink/directory marker. - X, - /// Pixel offset from the top edge of the cell; also used as image height - /// (with `change_drag_image`) or as a parent directory handle. - Y, + const data = cap.trailing(); - pub fn Type(comptime key: Option) type { - return switch (key) { - .t => EventType, - // The spec uses 32-bit signed or unsigned; we standardize on - // i32 because the location keys legitimately take -1 (drag - // leaves the window) and other keys never exceed i32 range. - .m, .i, .o, .x, .y, .X, .Y => i32, - }; - } + const metadata: []const u8, const payload: ?[]const u8 = result: { + const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; + break :result .{ data[0..sep], data[sep + 1 .. data.len] }; + }; - pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() { - const name = @tagName(key); + parser.command = .{ + .kitty_dnd_protocol = .{ + .metadata = metadata, + .payload = payload, + .terminator = .init(terminator_ch), + }, + }; - const value: []const u8 = value: { - var pos: usize = 0; - while (pos < metadata.len) { - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; - - // Case-sensitive match: x and X must not be confused. - if (!std.mem.startsWith(u8, metadata[pos..], name)) { - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; - pos += 1; - continue; - } - pos += name.len; - - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; - if (metadata[pos] != '=') return null; - - const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; - const start = pos + 1; - break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace); - } - return null; - }; - - return switch (key) { - .t => .init(value), - .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, - }; - } -}; + return &parser.command; +} test "OSC 72: metadata only, no payload" { const testing = std.testing; @@ -192,134 +108,19 @@ test "OSC 72: metadata and non-empty payload" { try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?); } -test "OSC 72: readOption .t valid event types" { +test "OSC 72: empty metadata with payload" { const testing = std.testing; var p: Parser = .init(testing.allocator); defer p.deinit(); - const cases = .{ - .{ "72;t=a", EventType.accept_drops }, - .{ "72;t=A", EventType.stop_accepting_drops }, - .{ "72;t=m", EventType.drop_move }, - .{ "72;t=M", EventType.drop_dropped }, - .{ "72;t=r", EventType.request_data }, - .{ "72;t=R", EventType.request_error }, - .{ "72;t=o", EventType.offer_drag }, - .{ "72;t=p", EventType.present_data }, - .{ "72;t=P", EventType.change_drag_image }, - .{ "72;t=e", EventType.drag_offer_event }, - .{ "72;t=E", EventType.drag_offer_error }, - .{ "72;t=k", EventType.uri_list_data }, - .{ "72;t=q", EventType.query }, - }; - - inline for (cases) |case| { - p.deinit(); - p = .init(testing.allocator); - for (case[0]) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?); - } -} - -test "OSC 72: readOption .t unknown value returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=z"; + const input = "72;;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null); -} - -test "OSC 72: readOption integer keys" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?); - try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?); - try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?); - try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); - try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?); - try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?); -} - -test "OSC 72: readOption negative sentinel (-1 for drag leave)" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=m:x=-1:y=-1"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?); -} - -test "OSC 72: readOption case-sensitive key matching" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - // x=10 must not be returned when asking for .X - const input = "72;x=10:Y=200"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); - try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null); -} - -test "OSC 72: readOption absent key returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null); -} - -test "OSC 72: readOption malformed integer returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;x=notanumber"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); + try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("payload", cmd.kitty_dnd_protocol.payload.?); } test "OSC 72: BEL terminator recorded" { @@ -335,29 +136,3 @@ test "OSC 72: BEL terminator recorded" { try testing.expect(cmd == .kitty_dnd_protocol); try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel); } - -pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { - assert(parser.state == .@"72"); - - const cap = if (parser.capture) |*c| c else { - parser.state = .invalid; - return null; - }; - - const data = cap.trailing(); - - const metadata: []const u8, const payload: ?[]const u8 = result: { - const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; - break :result .{ data[0..sep], data[sep + 1 .. data.len] }; - }; - - parser.command = .{ - .kitty_dnd_protocol = .{ - .metadata = metadata, - .payload = payload, - .terminator = .init(terminator_ch), - }, - }; - - return &parser.command; -} From af8d28a940beb3dd41d16a885c1f92c729aede37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 09:30:40 -0700 Subject: [PATCH 6/9] terminal/kitty: drag and drop stream handler --- src/terminal/Terminal.zig | 10 ++ src/terminal/c/terminal.zig | 1 + src/terminal/kitty/dnd_drop.zig | 2 +- src/terminal/stream.zig | 9 +- src/terminal/stream_terminal.zig | 253 +++++++++++++++++++++++++++++++ src/termio/stream_handler.zig | 1 + 6 files changed, 274 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b7176bba2..561728f5f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -91,6 +91,12 @@ mouse_shape: mouse.Shape = .text, /// Per-session Glyph Protocol registrations. glyph_glossary: glyph.Glossary = .empty, +/// Kitty drag and drop protocol (OSC 72) state. Allocated when a client +/// registers to accept drops (t=a) and freed when it unregisters (t=A), +/// so a terminal that never runs a drag and drop aware program pays +/// nothing for it. Non-null means a client currently accepts drops. +kitty_dnd: ?*kitty.dnd.State = null, + /// These are just a packed set of flags we may set on the terminal. flags: packed struct { // This supports a Kitty extension where programs using semantic @@ -353,6 +359,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.pwd.deinit(alloc); self.title.deinit(alloc); self.glyph_glossary.deinit(alloc); + if (self.kitty_dnd) |dnd| dnd.destroy(alloc); self.* = undefined; } @@ -4907,6 +4914,9 @@ pub fn fullReset(self: *Terminal) void { self.pwd.clearRetainingCapacity(); self.title.clearRetainingCapacity(); self.glyph_glossary.clearAndFree(self.gpa()); + // A reset only interrupts an in-progress chunked OSC 72 command; + // drag and drop registration survives, matching kitty. + if (self.kitty_dnd) |dnd| dnd.chunking = .{}; self.status_display = .main; self.scrolling_region = .{ .top = 0, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ba00f1385..6c35d6e06 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -657,6 +657,7 @@ fn wrap( .bell = &Effects.bellTrampoline, .color_scheme = &Effects.colorSchemeTrampoline, .desktop_notification = &Effects.desktopNotificationTrampoline, + .drag_and_drop = null, .device_attributes = &Effects.deviceAttributesTrampoline, .enquiry = &Effects.enquiryTrampoline, .xtversion = &Effects.xtversionTrampoline, diff --git a/src/terminal/kitty/dnd_drop.zig b/src/terminal/kitty/dnd_drop.zig index f9486f9e9..e3c7ee5ce 100644 --- a/src/terminal/kitty/dnd_drop.zig +++ b/src/terminal/kitty/dnd_drop.zig @@ -248,7 +248,7 @@ fn refuseDragOut( /// A protocol state change an embedder may need to act on, returned by /// `handleCommand` and delivered through the stream handler's -/// `dnd_event` effect. This is a flat enum so it can cross a C API +/// `drag_and_drop` effect. This is a flat enum so it can cross a C API /// unchanged; any details are read back from `Terminal.kitty_dnd`. pub const Event = enum { /// The client registered (t=a), re-registered, or unregistered diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5556ba808..cd62a316a 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -129,6 +129,7 @@ pub const Action = union(Key) { color_operation: ColorOperation, semantic_prompt: SemanticPrompt, kitty_clipboard: KittyClipboard, + kitty_dnd: KittyDnd, pub const Key = lib.Enum( lib.target, @@ -229,6 +230,7 @@ pub const Action = union(Key) { "color_operation", "semantic_prompt", "kitty_clipboard", + "kitty_dnd", }, ); @@ -449,6 +451,8 @@ pub const Action = union(Key) { pub const SemanticPrompt = osc.Command.SemanticPrompt; pub const KittyClipboard = osc.Command.KittyClipboardProtocol; + + pub const KittyDnd = osc.Command.KittyDndProtocol; }; /// Returns a type that can process a stream of tty control characters. @@ -2561,6 +2565,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.kitty_clipboard, v); }, + .kitty_dnd_protocol => |v| { + self.handler.vt(.kitty_dnd, v); + }, + .conemu_sleep, .conemu_show_message_box, .conemu_change_tab_title, @@ -2571,7 +2579,6 @@ pub fn Stream(comptime H: type) type { .conemu_output_environment_variable, .conemu_run_process, .kitty_text_sizing, - .kitty_dnd_protocol, .kitty_desktop_notification, .context_signal, => { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 03e6d1887..bcf8690bc 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const build_options = @import("terminal_options"); const testing = std.testing; const apc = @import("apc.zig"); @@ -16,6 +17,7 @@ const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); const kitty_clipboard = @import("kitty/clipboard.zig"); const kitty_color = @import("kitty/color.zig"); +const kitty_dnd = @import("kitty/dnd.zig"); const size_report = @import("size_report.zig"); const simd = @import("../simd/main.zig"); const terminfo = @import("../terminfo/main.zig"); @@ -115,6 +117,15 @@ pub const Handler = struct { /// valid for the duration of the callback. desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void, + /// Called when drag and drop protocol state changes in a way the + /// embedder may need to act on: the running program registering + /// or unregistering to accept drops, answering a drag, or + /// concluding a drop. The event says what changed; the details + /// are read from `handler.terminal.kitty_dnd` (Kitty's OSC 72 is + /// the only drag and drop protocol today). Native drag events + /// flow the other way, by calling `kitty.dnd.State` directly. + drag_and_drop: ?*const fn (*Handler, kitty_dnd.Event) void, + /// Called in response to a color scheme DSR query (CSI ? 996 n). /// Returns the current color scheme. Return null to silently /// ignore the query. @@ -207,6 +218,7 @@ pub const Handler = struct { .color_scheme = null, .desktop_notification = null, .device_attributes = null, + .drag_and_drop = null, .enquiry = null, .progress_report = null, .size = null, @@ -439,6 +451,12 @@ pub const Handler = struct { // Clipboard operations are external effects, not terminal state. log.warn("error handling clipboard operation err={}", .{err}); }, + .kitty_dnd => self.kittyDnd(value) catch |err| { + // Drag and drop is a self-contained subsystem: an OOM + // updating its state or a failure writing a response + // degrades it without corrupting terminal state, so we log. + log.warn("error handling kitty dnd err={}", .{err}); + }, .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), @@ -1080,6 +1098,40 @@ pub const Handler = struct { self.writePty(resp); } + /// Handle an OSC 72 drag and drop command. + fn kittyDnd( + self: *Handler, + v: Action.KittyDnd, + ) (Allocator.Error || std.Io.Writer.Error)!void { + // Responses are usually small (queries, errors) but data + // serving can produce many chunks, so fall back to the heap. + var stack = std.heap.stackFallback(512, self.terminal.gpa()); + const response_alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(response_alloc); + defer aw.deinit(); + + // The state is allocated on registration and owned by the + // terminal, so it uses the terminal's allocator, not the + // response's. + const event = try kitty_dnd.handleCommand( + &self.terminal.kitty_dnd, + self.terminal.gpa(), + &aw.writer, + v, + ); + + if (aw.written().len > 0) { + const written = aw.toOwnedSliceSentinel(0) catch return; + defer response_alloc.free(written); + self.writePty(written); + } + + if (event) |ev| { + const func = self.effects.drag_and_drop orelse return; + func(self, ev); + } + } + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { const func = self.effects.device_attributes orelse return; const attrs = func(self); @@ -5141,3 +5193,204 @@ test "continuation reconstructs standard stream without duplicate effects" { restored_terminal.screens.active.cursor.style_id, ); } + +test "kitty dnd: query response" { + const S = struct { + var pty: std.ArrayListUnmanaged(u8) = .empty; + fn writePty(_: *Handler, data: [:0]const u8) void { + pty.appendSlice(testing.allocator, data) catch unreachable; + } + }; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]72;t=q:i=3\x1B\\"); + try testing.expectEqualStrings("\x1b]72;t=q:i=3\x1b\\", S.pty.items); +} + +test "kitty dnd: register, drop, and serve data" { + const S = struct { + var pty: std.ArrayListUnmanaged(u8) = .empty; + fn writePty(_: *Handler, data: [:0]const u8) void { + pty.appendSlice(testing.allocator, data) catch unreachable; + } + }; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Client registers to accept drops. + s.nextSlice("\x1B]72;t=a;text/plain text/uri-list\x1B\\"); + try testing.expectEqualStrings("", S.pty.items); + try testing.expect(t.kitty_dnd != null); + + // A native drop arrives; the embedder feeds it to the terminal + // state and delivers the produced event bytes itself. + { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{ + .cell_x = 2, + .cell_y = 1, + .pixel_x = 20, + .pixel_y = 18, + .operations = .{ .copy = true }, + }, &.{ + .{ .mime = "text/plain", .data = "hello" }, + }); + try testing.expectEqualStrings( + "\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\", + aw.written(), + ); + } + + // The client requests the data and concludes. + s.nextSlice("\x1B]72;t=r:x=1\x1B\\"); + try testing.expectEqualStrings( + "\x1b]72;t=r:x=1:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\", + S.pty.items, + ); + S.pty.clearRetainingCapacity(); + + s.nextSlice("\x1B]72;t=r\x1B\\"); + try testing.expectEqualStrings("", S.pty.items); + try testing.expect(t.kitty_dnd.?.drop.items == null); +} + +test "kitty dnd: state updates work without write_pty effect" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); + defer s.deinit(); + + // Queries produce no output (nowhere to write) but registration + // state still updates. + s.nextSlice("\x1B]72;t=q\x1B\\"); + s.nextSlice("\x1B]72;t=a\x1B\\"); + try testing.expect(t.kitty_dnd != null); + + // The terminal remains functional. + s.nextSlice("ok"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ok", str); +} + +test "kitty dnd: registration survives terminal reset" { + const S = struct { + var pty: std.ArrayListUnmanaged(u8) = .empty; + fn writePty(_: *Handler, data: [:0]const u8) void { + pty.appendSlice(testing.allocator, data) catch unreachable; + } + }; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Start a chunked command, then reset mid-chunk. + s.nextSlice("\x1B]72;t=a:i=5\x1B\\"); + s.nextSlice("\x1B]72;t=m:o=1:m=1;text/pl\x1B\\"); + s.nextSlice("\x1Bc"); + + // Registration survives (matching kitty), chunking was interrupted + // so a new command is not treated as a continuation. + try testing.expect(t.kitty_dnd != null); + try testing.expect(!t.kitty_dnd.?.chunking.active); + s.nextSlice("\x1B]72;t=q\x1B\\"); + try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", S.pty.items); +} + +test "kitty dnd: effect reports registration, acceptance, and conclusion" { + const S = struct { + var events: std.ArrayListUnmanaged(kitty_dnd.Event) = .empty; + var mimes: std.ArrayListUnmanaged(u8) = .empty; + + fn clear() void { + events.deinit(testing.allocator); + events = .empty; + mimes.deinit(testing.allocator); + mimes = .empty; + } + + fn dragAndDrop(handler: *Handler, ev: kitty_dnd.Event) void { + events.append(testing.allocator, ev) catch unreachable; + // Registration details are read from the terminal state. + if (ev == .registration) { + mimes.clearRetainingCapacity(); + const state = handler.terminal.kitty_dnd orelse return; + var it = state.registeredMimes(); + while (it.next()) |m| { + mimes.appendSlice(testing.allocator, m) catch unreachable; + mimes.append(testing.allocator, ',') catch unreachable; + } + } + } + }; + S.clear(); + defer S.clear(); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.drag_and_drop = &S.dragAndDrop; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Registration with a MIME list, read back from the state. + s.nextSlice("\x1B]72;t=a;image/png text/plain\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.events.items.len); + try testing.expect(S.events.items[0] == .registration); + try testing.expectEqualStrings("image/png,text/plain,", S.mimes.items); + + // A native drag and the client's answer. + { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + } + s.nextSlice("\x1B]72;t=m:o=2;text/plain\x1B\\"); + try testing.expectEqual(@as(usize, 2), S.events.items.len); + try testing.expect(S.events.items[1] == .acceptance); + + // Conclusion carries the performed operation. + s.nextSlice("\x1B]72;t=r:o=2\x1B\\"); + try testing.expectEqual(@as(usize, 3), S.events.items.len); + try testing.expectEqual(kitty_dnd.Event.concluded_move, S.events.items[2]); + + // Unregistration reports with the state gone. + s.nextSlice("\x1B]72;t=A\x1B\\"); + try testing.expectEqual(@as(usize, 4), S.events.items.len); + try testing.expect(S.events.items[3] == .registration); + try testing.expect(t.kitty_dnd == null); + try testing.expectEqualStrings("", S.mimes.items); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 192e51644..1dc5268f7 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -357,6 +357,7 @@ pub const StreamHandler = struct { .title_push, .title_pop, .kitty_clipboard, + .kitty_dnd, => {}, } } From db2f8be59011b09e4943cade3299e83686dc68d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 09:35:06 -0700 Subject: [PATCH 7/9] terminal/kitty: dnd docs --- src/terminal/kitty/dnd.zig | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig index 42afcb749..3142a4158 100644 --- a/src/terminal/kitty/dnd.zig +++ b/src/terminal/kitty/dnd.zig @@ -1,8 +1,6 @@ //! Kitty drag and drop protocol (OSC 72). //! //! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/ -//! Reference implementation: kitty/dnd.c and kitty/screen.c in -//! https://github.com/kovidgoyal/kitty (introduced in kitty 0.47). //! //! The protocol lets a program running in the terminal participate in //! native OS drag and drop. A client registers to accept drops (t=a); @@ -10,27 +8,6 @@ //! (t=M) to it and serves the dropped data on request (t=r), instead //! of the traditional behavior of pasting dropped paths or text. //! -//! The implementation is split into: -//! -//! * dnd_command.zig: metadata grammar and typed command decoding, -//! including chunk reassembly. The grammar mirrors kitty's -//! generated parser exactly. -//! * dnd_response.zig: wire encoding for everything the terminal -//! sends, mirroring kitty's send_payload_to_child chunking. -//! * dnd_drop.zig: the per-terminal protocol state machine, driven -//! by client OSCs on one side and native drag events from the -//! embedder on the other. It is allocated when a client registers -//! to accept drops and freed when it unregisters, so terminals -//! that never see the protocol pay nothing for it. -//! -//! The wire behavior was validated against kitty's implementation -//! (kitty_tests/dnd.py is the oracle), including its deviations from -//! the published spec: the MIME list payload is sent on every move -//! event with a trailing space after each entry, a missing `t` key -//! ignores the command rather than defaulting to `a`, empty payloads -//! omit the `;` and `m=` entirely, and registration survives a -//! terminal reset (RIS clears only the chunk-reassembly flag). -//! //! ## Divergences //! //! These will be fixed in the future: From da0093671a12cdbdbe62b70099113cca454cd997 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:03:08 +0000 Subject: [PATCH 8/9] Update VOUCHED list (#13975) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/13899#discussioncomment-18120807) from @jcollie. Vouch: @j-c-m Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 110fb10ac..e48ebcde6 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -156,6 +156,7 @@ hulet i999rri icodesign illiakrauchanka +j-c-m j0hnm4r5 jacobsandlund jake-stewart From 5834a0e3df621802e9578e4562d88b0c2ad4ada8 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:15:39 +0000 Subject: [PATCH 9/9] Update VOUCHED list (#13977) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/13976#discussioncomment-18121426) from @pluiedev. Denounce: @tangivis Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index e48ebcde6..3d70361e8 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -300,6 +300,7 @@ slsrepo steven-tk sunshine-syz svmhdvn +-tangivis tasselx tbrundige tdgroot