From e03475c0cc52b9a6b01930380c61cd354e60a36a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 20:34:38 -0700 Subject: [PATCH] libghostty: clipboard_read effect, enables OSC52 reads This adds a `clipboard_read` effect to the stream terminal handler and a matching `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` callback to the libghostty-vt C API so that embedders can answer OSC 52 read requests (the `?` payload). This is a _blocking_ effect: if the embedder needs to ask the user for permission, the entire VT processing pipeline is _blocked_ during the callback. This is a purposeful simplification choice compared to how Ghostty GUI works with async requests. I think its reasonable, it eliminates a TON of complexity. If the effect isn't set, then any clipboard reads are denied. This can be expanded easily to support Kitty clipboard protocol later. --- example/c-vt-effects/src/main.c | 52 +++- include/ghostty/vt/terminal.h | 196 +++++++++++++- src/lib/types.zig | 1 + src/lib_vt.zig | 1 + src/terminal/c/terminal.zig | 252 ++++++++++++++++++ src/terminal/c/types.zig | 3 + src/terminal/clipboard.zig | 95 +++++++ src/terminal/osc.zig | 7 +- .../osc/parsers/clipboard_operation.zig | 18 +- src/terminal/stream.zig | 2 + src/terminal/stream_terminal.zig | 247 ++++++++++++++++- 11 files changed, 852 insertions(+), 22 deletions(-) diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c index bc5ece06d..8568a5349 100644 --- a/example/c-vt-effects/src/main.c +++ b/example/c-vt-effects/src/main.c @@ -70,6 +70,46 @@ GhosttyClipboardWriteResult on_clipboard_write( } //! [effects-clipboard-write] +//! [effects-clipboard-read] +void on_clipboard_read( + GhosttyTerminal terminal, + void* userdata, + const GhosttyClipboardRead* read) { + (void)terminal; + (void)userdata; + + // The read is synchronous: a real embedder would ask the user for + // permission here (unless read->granted) and the VT stream waits until + // this callback returns. The reply is sent to the program through the + // write_pty callback. + printf(" clipboard read (location=%d, mimes=%zu)\n", + (int)read->location, read->mimes_len); + for (size_t i = 0; i < read->mimes_len; i++) { + printf(" "); + fwrite(read->mimes[i].ptr, 1, read->mimes[i].len, stdout); + printf("\n"); + } + + // Reply with every requested representation we have. This example only + // has text. + const char* text = "Hello from the clipboard"; + GhosttyClipboardContent content = { + .mime = {.ptr = (const uint8_t*)"text/plain", .len = 10}, + .data = {.ptr = (const uint8_t*)text, .len = strlen(text)}, + }; + GhosttyClipboardReadReply reply = { + .size = sizeof(reply), + .result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS, + .contents = &content, + .contents_len = 1, + .available = NULL, + .available_len = 0, + .remember = false, + }; + read->reply(read, &reply); +} +//! [effects-clipboard-read] + //! [effects-unknown-sequence] void on_unknown_sequence( GhosttyTerminal terminal, @@ -118,6 +158,8 @@ int main() { (const void *)on_title_changed); ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void *)on_clipboard_write); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ, + (const void *)on_clipboard_read); ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE, (const void *)on_unknown_sequence); @@ -154,13 +196,19 @@ int main() { ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq, strlen(clipboard_seq)); - // 5. Unsupported APC sequence + // 5. Clipboard read (OSC 52 ; c ; ? ST) + printf("Sending clipboard read:\n"); + const char* clipboard_read_seq = "\x1B]52;c;?\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_read_seq, + strlen(clipboard_read_seq)); + + // 6. Unsupported APC sequence printf("Sending unknown APC:\n"); const char* unknown_apc = "\x1B_private-command;payload\x1B\\"; ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc, strlen(unknown_apc)); - // 6. Another bell to show the counter increments + // 7. Another bell to show the counter increments printf("Sending another BEL:\n"); ghostty_terminal_vt_write(terminal, &bel, 1); diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 5133756f9..89ae241d6 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -96,6 +96,7 @@ extern "C" { * | `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_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 | @@ -112,6 +113,9 @@ extern "C" { * ### Defining a clipboard_write callback * @snippet c-vt-effects/src/main.c effects-clipboard-write * + * ### Defining a clipboard_read callback + * @snippet c-vt-effects/src/main.c effects-clipboard-read + * * ### Defining an unknown_sequence callback * @snippet c-vt-effects/src/main.c effects-unknown-sequence * @@ -522,8 +526,8 @@ typedef enum GHOSTTY_ENUM_TYPED { * 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 always ignored and never - * forwarded to this callback. + * shape. OSC 52 clipboard read requests ("?") are delivered to + * GhosttyTerminalClipboardReadFn instead. * * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA @@ -537,6 +541,180 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( void* userdata, const GhosttyClipboardWrite* write); +/** + * Result of a clipboard read reply. + * + * @ingroup terminal + */ +typedef enum { + /** The clipboard was read; the reply carries its contents. */ + GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS = 0, + + /** The clipboard read was denied by policy or the user. */ + GHOSTTY_CLIPBOARD_READ_RESULT_DENIED = 1, + + /** The embedder cannot read this clipboard. */ + GHOSTTY_CLIPBOARD_READ_RESULT_UNSUPPORTED = 2, + + /** The clipboard is temporarily unavailable. */ + GHOSTTY_CLIPBOARD_READ_RESULT_BUSY = 3, + + /** Reading the clipboard failed due to an I/O error. */ + GHOSTTY_CLIPBOARD_READ_RESULT_IO_ERROR = 4, + GHOSTTY_CLIPBOARD_READ_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyClipboardReadResult; + +/** + * The reply to a clipboard read request. + * + * This is a sized struct; set `size` to `sizeof(GhosttyClipboardReadReply)`. + * All arrays and the strings they point to are borrowed only for the + * 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 + * 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 + * such as "text/plain". + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Outcome of the read. */ + GhosttyClipboardReadResult result; + + /** Borrowed array of MIME representations of the clipboard contents. */ + const GhosttyClipboardContent* contents; + + /** Number of entries in contents. */ + size_t contents_len; + + /** + * Borrowed array of all MIME types available on the clipboard. Only + * used when GhosttyClipboardRead::list is set; may be NULL otherwise. + */ + const GhosttyString* available; + + /** Number of entries in available. */ + size_t available_len; + + /** + * Record a session grant so future requests from the same program skip + * the permission prompt. Only honored on success when + * GhosttyClipboardRead::can_remember is set. + */ + bool remember; +} GhosttyClipboardReadReply; + +typedef struct GhosttyClipboardRead GhosttyClipboardRead; + +/** + * Function type used to answer a clipboard read request. Obtained from + * GhosttyClipboardRead::reply; see that struct for the contract. + * + * @param read The request being answered + * @param reply The reply, borrowed only for the duration of this call + * + * @ingroup terminal + */ +typedef void (*GhosttyClipboardReadReplyFn)( + const GhosttyClipboardRead* read, + const GhosttyClipboardReadReply* reply); + +/** + * A synchronous request to read clipboard contents. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. The request is borrowed and valid only for the + * callback duration. + * + * The read is answered by calling `reply` with this request and a + * 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. + * + * @ingroup terminal + */ +struct GhosttyClipboardRead { + /** Size of this struct in bytes. */ + size_t size; + + /** Clipboard to read. */ + GhosttyClipboardLocation location; + + /** + * Borrowed array of the MIME types the program wants, in order of + * preference. Protocols that only carry text (OSC 52) request + * "text/plain". NULL when mimes_len is zero. + */ + const GhosttyString* mimes; + + /** Number of entries in mimes. */ + size_t mimes_len; + + /** + * True if the program also wants the list of MIME types available on the + * clipboard, delivered through GhosttyClipboardReadReply::available. + */ + bool list; + + /** + * Name of the requesting program for permission prompts, if the protocol + * carries one. Empty otherwise. + */ + GhosttyString name; + + /** + * True if the terminal already holds a session grant for this request + * (kitty clipboard protocol passwords). The embedder should skip any + * permission prompt and serve the read. + */ + bool granted; + + /** + * True if the program supplied a session password, so the embedder may + * offer to remember the user's decision through + * GhosttyClipboardReadReply::remember. When false, remember is ignored. + */ + bool can_remember; + + /** Terminal-owned reply state. Do not access. */ + const void* ctx; + + /** Answer the read; see the struct documentation. */ + GhosttyClipboardReadReplyFn reply; +}; + +/** + * 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. + * + * Answer by calling `read->reply(read, &reply)` before returning. See + * GhosttyClipboardRead for the full contract. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param read Borrowed clipboard read request + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalClipboardReadFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyClipboardRead* read); + /** * A request to show a desktop notification. * @@ -1067,8 +1245,8 @@ 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 always ignored; see - * GhosttyTerminalClipboardWriteFn. + * Clipboard read requests are delivered to + * GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead. * * Input type: GhosttyTerminalClipboardWriteFn */ @@ -1230,6 +1408,16 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyString* */ GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37, + + /** + * 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). + * + * Input type: GhosttyTerminalClipboardReadFn + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ = 38, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/lib/types.zig b/src/lib/types.zig index 655fdc220..010522496 100644 --- a/src/lib/types.zig +++ b/src/lib/types.zig @@ -8,6 +8,7 @@ pub const String = extern struct { .ptr = zig.ptr, .len = zig.len, }, + else => @compileError("unsupported String.init type: " ++ @typeName(@TypeOf(zig))), }; } }; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 085eb78cd..1cc5c9ed3 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -53,6 +53,7 @@ pub const apc = terminal.apc; 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 5bfa45ba7..947504582 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -138,6 +138,42 @@ pub const ClipboardWrite = extern struct { contents_len: usize, }; +/// The reply to a clipboard read request. +/// +/// C: GhosttyClipboardReadReply +pub const ClipboardReadReply = extern struct { + size: usize, + result: clipboard.Read.Status, + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const lib.String, + available_len: usize, + remember: bool, +}; + +/// A synchronous request to read clipboard contents. The embedder answers +/// by calling `reply` with the request before the callback returns. +/// +/// C: GhosttyClipboardRead +pub const ClipboardRead = extern struct { + size: usize, + location: clipboard.Location, + mimes: ?[*]const lib.String, + mimes_len: usize, + list: bool, + name: lib.String, + granted: bool, + can_remember: bool, + /// Terminal-owned reply state; opaque to the embedder. + ctx: *const anyopaque, + reply: ClipboardReadReplyFn, +}; + +/// C function pointer type for replying to a clipboard read. +/// +/// C: GhosttyClipboardReadReplyFn +pub const ClipboardReadReplyFn = *const fn (*const ClipboardRead, *const ClipboardReadReply) callconv(lib.calling_conv) void; + /// A request to show a desktop notification. /// /// C: GhosttyTerminalDesktopNotification @@ -220,6 +256,7 @@ const Effects = struct { progress_report: ?ProgressReportFn = null, size_cb: ?SizeFn = null, clipboard_write: ?ClipboardWriteFn = null, + clipboard_read: ?ClipboardReadFn = null, unknown_sequence: ?UnknownSequenceFn = null, /// Scratch buffer for DA1 feature codes. The device attributes @@ -255,6 +292,11 @@ const Effects = struct { /// and its contents are borrowed and only valid for the callback duration. pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult; + /// C function pointer type for the clipboard_read callback. The request + /// is borrowed for the callback duration and must be answered through + /// its reply function before the callback returns. + pub const ClipboardReadFn = *const fn (Terminal, ?*anyopaque, *const ClipboardRead) callconv(lib.calling_conv) void; + /// C function pointer type for the desktop_notification callback. The /// request and its strings are borrowed for the callback duration. pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void; @@ -356,6 +398,100 @@ const Effects = struct { return func(@ptrCast(wrapper), wrapper.effects.userdata, &request); } + /// Opaque context behind ClipboardRead.ctx for the reply trampoline. + const ClipboardReadCtx = struct { + read: clipboard.Read, + wrapper: *TerminalWrapper, + }; + + fn clipboardReadTrampoline(handler: *Handler, read: clipboard.Read) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.clipboard_read orelse return; + + // Requests carry a handful of MIME types, so keep the common case + // allocation-free. On OOM the request goes unanswered and the + // handler replies with an empty clipboard. + var sfa = std.heap.stackFallback(128, wrapper.terminal.gpa()); + const alloc = sfa.get(); + const mimes = alloc.alloc(lib.String, read.mimes.len) catch { + log.warn("out of memory converting clipboard read request", .{}); + return; + }; + defer alloc.free(mimes); + for (mimes, read.mimes) |*c_mime, mime| c_mime.* = .init(mime); + + const ctx: ClipboardReadCtx = .{ .read = read, .wrapper = wrapper }; + const request: ClipboardRead = .{ + .size = @sizeOf(ClipboardRead), + .location = read.location, + .mimes = if (mimes.len > 0) mimes.ptr else null, + .mimes_len = mimes.len, + .list = read.list, + .name = .init(read.name), + .granted = read.granted, + .can_remember = read.can_remember, + .ctx = &ctx, + .reply = &clipboardReadReplyTrampoline, + }; + func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + + fn clipboardReadReplyTrampoline( + request: *const ClipboardRead, + reply: *const ClipboardReadReply, + ) callconv(lib.calling_conv) void { + const ctx: *const ClipboardReadCtx = @ptrCast(@alignCast(request.ctx)); + const read = ctx.read; + switch (reply.result) { + .success => {}, + .denied => return read.reply(.denied), + .busy => return read.reply(.busy), + .io_error => return read.reply(.io_error), + .unsupported, _ => return read.reply(.unsupported), + } + + const c_contents: []const ClipboardContent = if (reply.contents) |ptr| + ptr[0..reply.contents_len] + else + &.{}; + const c_available: []const lib.String = if (reply.available) |ptr| + ptr[0..reply.available_len] + else + &.{}; + + // Most replies carry one representation, so keep that path + // allocation-free while supporting arbitrary multi-MIME replies. + // On OOM we don't reply and the handler answers with an empty + // clipboard. + var sfa = std.heap.stackFallback(256, ctx.wrapper.terminal.gpa()); + const alloc = sfa.get(); + const contents = alloc.alloc(clipboard.Content, c_contents.len) catch { + log.warn("out of memory converting clipboard read reply", .{}); + return; + }; + defer alloc.free(contents); + for (contents, c_contents) |*content, c_content| { + content.* = .{ + .mime = c_content.mime.ptr[0..c_content.mime.len], + .data = c_content.data.ptr[0..c_content.data.len], + }; + } + const available = alloc.alloc([]const u8, c_available.len) catch { + log.warn("out of memory converting clipboard read reply", .{}); + return; + }; + defer alloc.free(available); + for (available, c_available) |*mime, c_mime| { + mime.* = c_mime.ptr[0..c_mime.len]; + } + + read.reply(.{ .success = .{ + .contents = contents, + .available = available, + .remember = reply.remember, + } }); + } + fn desktopNotificationTrampoline( handler: *Handler, notification: Action.ShowDesktopNotification, @@ -525,6 +661,7 @@ fn wrap( .progress_report = &Effects.progressReportTrampoline, .size = &Effects.sizeTrampoline, .clipboard_write = &Effects.clipboardWriteTrampoline, + .clipboard_read = null, }; wrapper.* = .{ @@ -1021,6 +1158,7 @@ pub const Option = enum(c_int) { unknown_sequence = 35, unknown_max_bytes = 36, terminfo_name = 37, + clipboard_read = 38, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -1038,6 +1176,7 @@ pub const Option = enum(c_int) { .progress_report => ?Effects.ProgressReportFn, .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, + .clipboard_read => ?Effects.ClipboardReadFn, .unknown_sequence => ?Effects.UnknownSequenceFn, .title, .pwd, .terminfo_name => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, @@ -1106,6 +1245,13 @@ fn setTyped( .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, .clipboard_write => wrapper.effects.clipboard_write = value, + .clipboard_read => { + wrapper.effects.clipboard_read = value; + wrapper.stream.handler.effects.clipboard_read = if (value != null) + &Effects.clipboardReadTrampoline + else + null; + }, .unknown_sequence => { wrapper.effects.unknown_sequence = value; wrapper.stream.handler.unknown_sequence = if (value != null) @@ -4600,6 +4746,112 @@ test "clipboard_write without callback is unsupported and silent" { try testing.expectEqual(clipboard.WriteResult.unsupported, result); } +test "set clipboard_read callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + var count: usize = 0; + var last_size: usize = 0; + var last_location: clipboard.Location = .standard; + var last_mimes_len: usize = 0; + var last_mime_is_text: bool = false; + var last_list: bool = true; + var last_name_len: usize = 1; + var last_granted: bool = true; + var last_can_remember: bool = true; + var result: clipboard.Read.Status = .success; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn clipboardRead( + _: Terminal, + _: ?*anyopaque, + request: *const ClipboardRead, + ) callconv(lib.calling_conv) void { + count += 1; + last_size = request.size; + last_location = request.location; + 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 = result, + .contents = &contents, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .remember = false, + }); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // Without a callback the handler effect is unset and reads are silent. + try testing.expect(t.?.stream.handler.effects.clipboard_read == null); + const read_st = "\x1B]52;c;?\x1B\\"; + vt_write(t, read_st, read_st.len); + try testing.expect(S.last_data == null); + + try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&S.clipboardRead))); + try testing.expect(t.?.stream.handler.effects.clipboard_read != null); + + const read_bel = "\x1B]52;p;?\x07"; + vt_write(t, read_bel, read_bel.len); + try testing.expectEqual(1, S.count); + try testing.expectEqual(@sizeOf(ClipboardRead), S.last_size); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqual(1, S.last_mimes_len); + try testing.expect(S.last_mime_is_text); + try testing.expect(!S.last_list); + try testing.expectEqual(0, S.last_name_len); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.last_data.?); + + // Denied replies with an empty clipboard. + S.result = .denied; + vt_write(t, read_st, read_st.len); + try testing.expectEqual(2, S.count); + try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.last_data.?); + + // Clearing the callback uninstalls the handler effect. + try testing.expectEqual(Result.success, set(t, .clipboard_read, null)); + try testing.expect(t.?.stream.handler.effects.clipboard_read == null); +} + test "pwd_changed without callback is silent" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 95a166c3d..0367a35b6 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -174,6 +174,8 @@ const type_decls = [_]TypeDecl{ .initStruct("GhosttyBuffer", lib.Buffer), .initStruct("GhosttyCellsView", cell.CellsView), .initStruct("GhosttyClipboardContent", terminal.ClipboardContent), + .initStruct("GhosttyClipboardRead", terminal.ClipboardRead), + .initStruct("GhosttyClipboardReadReply", terminal.ClipboardReadReply), .initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite), .initStruct("GhosttyCodepoints", Codepoints), .initStruct("GhosttyColorPaletteMask", color_c.PaletteMask), @@ -241,6 +243,7 @@ const type_decls = [_]TypeDecl{ .initEnum("GhosttyCellSemanticContent", cell.SemanticContent, "GHOSTTY_CELL_SEMANTIC_"), .initEnum("GhosttyCellWide", cell.Wide, "GHOSTTY_CELL_WIDE_"), .initEnum("GhosttyClipboardLocation", clipboard.Location, "GHOSTTY_CLIPBOARD_LOCATION_"), + .initEnum("GhosttyClipboardReadResult", clipboard.Read.Status, "GHOSTTY_CLIPBOARD_READ_RESULT_"), .initEnum("GhosttyClipboardWriteResult", clipboard.WriteResult, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"), .initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"), .initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"), diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig index 170aa0725..836df373e 100644 --- a/src/terminal/clipboard.zig +++ b/src/terminal/clipboard.zig @@ -52,6 +52,101 @@ pub const WriteResult = enum(c_int) { _, }; +/// A request from the running program to read a clipboard. +/// +/// Reads are synchronous: the effect callback must answer through `reply` +/// before it returns, and the request (including its reply context) is +/// invalid afterwards. An embedder that needs user consent must block until +/// it has an answer; the VT stream waits with it. +pub const Read = struct { + location: Location, + + /// The MIME types the program wants, in order of preference. The + /// reply should carry every requested representation the clipboard + /// has; unrequested ones are ignored. Protocols that only carry text + /// (OSC 52) request "text/plain". + mimes: []const []const u8, + + /// The program also wants the list of MIME types available on the + /// clipboard, delivered as Result.Success.available. + list: bool, + + /// Name of the requesting program for permission prompts, if the + /// protocol carries one. Empty otherwise. + name: []const u8, + + /// True if the terminal already holds a session grant for this + /// request (kitty clipboard protocol passwords). The embedder should + /// skip any permission prompt and serve the read. + granted: bool, + + /// True if the program supplied a session password, so the embedder + /// may offer to remember the user's decision via + /// Result.Success.remember. When false, remember is ignored. + can_remember: bool, + + /// Terminal-owned reply state, only valid during the callback. + /// + /// The result is delivered through a call rather than returned so + /// the terminal consumes it while the embedder's memory is still + /// alive; a returned slice would have to outlive the callback. + reply_ctx: *anyopaque, + reply_fn: *const fn (*anyopaque, Result) void, + + /// Answer the read. May be called at most once; later calls are + /// ignored. Result memory is borrowed only for the duration of this + /// call. + pub fn reply(self: Read, result: Result) void { + self.reply_fn(self.reply_ctx, result); + } + + /// The status of a clipboard read reply. + pub const Status = enum(c_int) { + success = 0, + denied = 1, + unsupported = 2, + busy = 3, + io_error = 4, + _, + }; + + /// The reply to a clipboard read. + pub const Result = union(enum) { + /// The read was denied by policy or the user. + denied, + + /// The embedder cannot read this clipboard. + unsupported, + + /// The clipboard is temporarily unavailable. + busy, + + /// Reading the clipboard failed. + io_error, + + /// The read succeeded. All memory is borrowed for the duration + /// of the reply call. + success: Success, + + pub const Success = struct { + /// Representations of the clipboard contents, one per + /// requested MIME type the clipboard has. Protocols that + /// carry a single text value use the first entry with a + /// text MIME type (see isTextMime). + contents: []const Content = &.{}, + + /// All MIME types available on the clipboard. Only used when + /// the request set `list`. + available: []const []const u8 = &.{}, + + /// Record a session grant so future requests from the same + /// program skip the permission prompt. Only honored when the + /// request set `can_remember`. + remember: bool = false, + }; + }; +}; + test isTextMime { const testing = std.testing; try testing.expect(isTextMime("text/plain")); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 7edabcdc0..677152518 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -46,12 +46,13 @@ pub const Command = union(Key) { /// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md semantic_prompt: SemanticPrompt, - /// Set or get clipboard contents. If data is null, then the current - /// clipboard contents are sent to the pty. If data is set, this - /// contents is set on the clipboard. + /// Set or get clipboard contents. If data is "?", then the current + /// clipboard contents are sent to the pty. Otherwise, the contents + /// are set on the clipboard. clipboard_contents: struct { kind: u8, data: [:0]const u8, + terminator: Terminator = .st, }, /// OSC 7. Reports the current working directory of the shell. This is diff --git a/src/terminal/osc/parsers/clipboard_operation.zig b/src/terminal/osc/parsers/clipboard_operation.zig index ec8836fb0..015887716 100644 --- a/src/terminal/osc/parsers/clipboard_operation.zig +++ b/src/terminal/osc/parsers/clipboard_operation.zig @@ -6,7 +6,7 @@ const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; /// Parse OSC 52 -pub fn parse(parser: *Parser, _: ?u8) ?*Command { +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"52"); const cap = if (parser.capture) |*c| c else { parser.state = .invalid; @@ -26,6 +26,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { .clipboard_contents = .{ .kind = 'c', .data = data[1 .. data.len - 1 :0], + .terminator = .init(terminator_ch), }, }; } else { @@ -41,6 +42,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { .clipboard_contents = .{ .kind = data[0], .data = data[2 .. data.len - 1 :0], + .terminator = .init(terminator_ch), }, }; } @@ -59,6 +61,20 @@ test "OSC 52: get/set clipboard" { try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expectEqualStrings("?", cmd.clipboard_contents.data); + try testing.expectEqual(.st, cmd.clipboard_contents.terminator); +} + +test "OSC 52: get clipboard with BEL terminator" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "52;c;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(0x07).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual(.bel, cmd.clipboard_contents.terminator); } test "OSC 52: get/set clipboard (optional parameter)" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index cb684fa50..5556ba808 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -414,6 +414,7 @@ pub const Action = union(Key) { pub const ClipboardContents = struct { kind: u8, data: []const u8, + terminator: osc.Terminator, pub const C = extern struct { kind: u8, @@ -2500,6 +2501,7 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.clipboard_contents, .{ .kind = clip.kind, .data = clip.data, + .terminator = clip.terminator, }); }, diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index d19324886..10f239831 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -145,12 +145,23 @@ 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 never - /// forwarded: answering one would let any program running in the - /// terminal silently read the user's clipboard, and a VT state - /// library has no way to mediate that with user consent. + /// Clipboard read requests (OSC 52 with a "?" payload) are + /// delivered to clipboard_read instead. 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. + /// + /// 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. + clipboard_read: ?*const fn (*Handler, clipboard.Read) void, + /// Called in response to an XTVERSION query. Returns the version /// string to report (e.g. "ghostty 1.2.3"). The returned memory /// must be valid for the lifetime of the call. The maximum length @@ -162,6 +173,7 @@ pub const Handler = struct { /// effects beyond that. pub const readonly: Effects = .{ .bell = null, + .clipboard_read = null, .clipboard_write = null, .color_scheme = null, .desktop_notification = null, @@ -386,9 +398,10 @@ pub const Handler = struct { .clipboard_contents => self.clipboardContents( value.kind, value.data, + value.terminator, ) catch |err| { - // Clipboard writes are external effects, not terminal state. - log.warn("error handling clipboard write err={}", .{err}); + // Clipboard operations are external effects, not terminal state. + log.warn("error handling clipboard operation err={}", .{err}); }, .dcs_hook => try self.dcsHook(value), @@ -507,18 +520,26 @@ pub const Handler = struct { func(self, report); } - fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void { - const func = self.effects.clipboard_write orelse return; - - // Read requests are deliberately not forwarded; see the effect docs. - if (data.len == 1 and data[0] == '?') return; - + fn clipboardContents( + self: *Handler, + kind: u8, + data: []const u8, + terminator: osc.Terminator, + ) !void { const location: clipboard.Location = switch (kind) { 's' => .selection, 'p' => .primary, else => .standard, }; + // OSC 52 uses a "?" payload to request the clipboard contents. + if (data.len == 1 and data[0] == '?') { + self.clipboardRead(location, terminator); + return; + } + + const func = self.effects.clipboard_write orelse return; + // OSC 52 uses an empty payload to clear the selected clipboard. if (data.len == 0) { _ = func(self, .{ @@ -546,6 +567,94 @@ pub const Handler = struct { }); } + fn clipboardRead( + self: *Handler, + location: clipboard.Location, + terminator: osc.Terminator, + ) void { + const func = self.effects.clipboard_read orelse return; + + var state: ClipboardReadState = .{ + .handler = self, + .location = location, + .terminator = terminator, + }; + func(self, .{ + .location = location, + .mimes = &.{"text/plain"}, + .list = false, + .name = "", + .granted = false, + .can_remember = false, + .reply_ctx = &state, + .reply_fn = &ClipboardReadState.reply, + }); + + // The program is waiting on us, so a callback that returned + // without a (successful) reply gets an empty clipboard rather + // than silence. + if (!state.replied) state.respond("") catch |err| { + log.warn("error replying to clipboard read err={}", .{err}); + }; + } + + /// Reply state for one synchronous clipboard read. This lives on the + /// clipboardRead stack frame, so it is only valid during the callback. + const ClipboardReadState = struct { + handler: *Handler, + location: clipboard.Location, + terminator: osc.Terminator, + replied: bool = false, + + fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void { + const self: *ClipboardReadState = @ptrCast(@alignCast(ctx)); + if (self.replied) { + log.warn("clipboard read replied more than once, ignoring", .{}); + return; + } + + // OSC 52 carries a single text value. + const data: []const u8 = switch (result) { + .denied, .unsupported, .busy, .io_error => "", + .success => |s| for (s.contents) |c| { + if (clipboard.isTextMime(c.mime)) break c.data; + } else "", + }; + + self.respond(data) catch |err| { + // Leave replied unset so clipboardRead falls back to the + // empty reply. + log.warn("error replying to clipboard read err={}", .{err}); + return; + }; + self.replied = true; + } + + fn respond( + self: *ClipboardReadState, + data: []const u8, + ) error{ OutOfMemory, WriteFailed }!void { + const handler = self.handler; + var stack = std.heap.stackFallback(256, handler.terminal.gpa()); + const alloc = stack.get(); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + const kind: u8 = switch (self.location) { + .selection => 's', + .primary => 'p', + .standard, _ => 'c', + }; + try aw.writer.print("\x1b]52;{c};", .{kind}); + try std.base64.standard.Encoder.encodeWriter(&aw.writer, data); + try aw.writer.writeAll(self.terminator.string()); + + const written = try aw.toOwnedSliceSentinel(0); + defer alloc.free(written); + handler.writePty(written); + } + }; + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { const func = self.effects.device_attributes orelse return; const attrs = func(self); @@ -2640,6 +2749,120 @@ test "clipboard_write effect callback" { try testing.expectEqual(clipboard.WriteResult.denied, S.result); } +test "clipboard_read effect callback" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: std.ArrayList(u8) = .empty; + var count: usize = 0; + var last_location: clipboard.Location = .standard; + var last_mimes: []const []const u8 = &.{}; + var last_list: bool = true; + var last_name: []const u8 = "unset"; + var last_granted: bool = true; + var last_can_remember: bool = true; + var result: ?clipboard.Read.Result = .{ .success = .{ .contents = &.{.{ + .mime = "text/plain", + .data = "hello", + }} } }; + var reply_twice: bool = false; + + fn writePty(_: *Handler, data: [:0]const u8) void { + written.appendSlice(testing.allocator, data) catch @panic("OOM"); + } + + fn clipboardRead(_: *Handler, read: clipboard.Read) void { + count += 1; + last_location = read.location; + last_mimes = read.mimes; + last_list = read.list; + last_name = read.name; + last_granted = read.granted; + last_can_remember = read.can_remember; + if (result) |r| read.reply(r); + if (reply_twice) read.reply(.{ .success = .{ .contents = &.{.{ + .mime = "text/plain", + .data = "again", + }} } }); + } + }; + defer S.written.deinit(testing.allocator); + + // A null callback (the default readonly effects) silently ignores reads. + { + 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]52;c;?\x1B\\"); + try testing.expectEqual(0, S.written.items.len); + } + + 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(); + + // Success echoes the normalized selector and request terminator. + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqual(1, S.count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqual(1, S.last_mimes.len); + try testing.expectEqualStrings("text/plain", S.last_mimes[0]); + try testing.expect(!S.last_list); + try testing.expectEqualStrings("", S.last_name); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items); + + S.written.clearRetainingCapacity(); + s.nextSlice("\x1B]52;p;?\x07"); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.written.items); + + // Only the first text representation is used. + S.written.clearRetainingCapacity(); + S.result = .{ + .success = .{ + .contents = &.{ + .{ .mime = "image/png", .data = "\x89PNG" }, + .{ .mime = "UTF8_STRING", .data = "hi" }, + }, + // OSC 52 has no session passwords, so remember is ignored. + .remember = true, + }, + }; + s.nextSlice("\x1B]52;s;?\x1B\\"); + try testing.expectEqual(clipboard.Location.selection, S.last_location); + try testing.expectEqualStrings("\x1B]52;s;aGk=\x1B\\", S.written.items); + + // Every failure, no text, and no reply all answer with an empty + // clipboard. + for ([_]?clipboard.Read.Result{ + .denied, + .unsupported, + .busy, + .io_error, + .{ .success = .{} }, + null, + }) |result| { + S.written.clearRetainingCapacity(); + S.result = result; + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.written.items); + } + + // A second reply is ignored. + S.written.clearRetainingCapacity(); + S.result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } }; + S.reply_twice = true; + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items); +} + test "clipboard_write allocation failure is ignored" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator);