diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c index 8568a5349..08cfa4705 100644 --- a/example/c-vt-effects/src/main.c +++ b/example/c-vt-effects/src/main.c @@ -40,13 +40,17 @@ void on_title_changed(GhosttyTerminal terminal, void* userdata) { //! [effects-title-changed] //! [effects-clipboard-write] -GhosttyClipboardWriteResult on_clipboard_write( +void on_clipboard_write( GhosttyTerminal terminal, void* userdata, const GhosttyClipboardWrite* write) { (void)terminal; (void)userdata; + // The write is synchronous: a real embedder would ask the user for + // permission here (unless write->granted) and the VT stream waits until + // this callback returns. The replied result is sent to the program + // (OSC 5522) through the write_pty callback. printf(" clipboard write (location=%d, contents=%zu)\n", (int)write->location, write->contents_len); if (write->contents_len == 0) { @@ -66,7 +70,12 @@ GhosttyClipboardWriteResult on_clipboard_write( printf("\n"); } - return GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS; + GhosttyClipboardWriteReply reply = { + .size = sizeof(reply), + .result = GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS, + .remember = false, + }; + write->reply(write, &reply); } //! [effects-clipboard-write] diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 4f8999a03..16bd92c0a 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -463,41 +463,7 @@ typedef struct { } GhosttyClipboardContent; /** - * A semantic, atomic clipboard write. - * - * This is a sized struct. The callback must only access fields present in the - * size reported by `size`. The request, contents array, MIME strings, and - * data strings are all borrowed and valid only for the callback duration. - * - * All entries in `contents` are representations of the same logical value - * and must be committed atomically. A `contents_len` of zero requests that - * the destination be cleared. This is distinct from a content entry whose data - * has zero length. - * - * @ingroup terminal - */ -typedef struct { - /** Size of this struct in bytes. */ - size_t size; - - /** Clipboard destination. */ - GhosttyClipboardLocation location; - - /** Borrowed array of MIME representations. */ - const GhosttyClipboardContent* contents; - - /** Number of entries in contents; zero means clear the destination. */ - size_t contents_len; -} GhosttyClipboardWrite; - -/** - * Result of a clipboard write callback. - * - * Protocols without write acknowledgements, including OSC 52 and iTerm2 - * 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. + * Result of a clipboard write reply. * * @ingroup terminal */ @@ -522,21 +488,126 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyClipboardWriteResult; +/** + * The reply to a clipboard write request. + * + * This is a sized struct; set `size` to `sizeof(GhosttyClipboardWriteReply)`. + * The reply is borrowed only for the duration of the reply call and may be + * freed as soon as it returns. + * + * The result answers the program with the matching protocol status for + * protocols with a write acknowledgement (OSC 5522: DONE, EPERM, ENOSYS, + * EBUSY, EINVAL, EIO); protocols without one (OSC 52, OSC 1337 Copy) + * discard the reply. `remember` is ignored on any result other than + * GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Outcome of the write. */ + GhosttyClipboardWriteResult result; + + /** + * Record a session grant so future requests from the same program skip + * the permission prompt. Only honored on success when + * GhosttyClipboardWrite::can_remember is set. + */ + bool remember; +} GhosttyClipboardWriteReply; + +typedef struct GhosttyClipboardWrite GhosttyClipboardWrite; + +/** + * Function type used to answer a clipboard write request. Obtained from + * GhosttyClipboardWrite::reply; see that struct for the contract. + * + * @param write The request being answered + * @param reply The reply, borrowed only for the duration of this call + * + * @ingroup terminal + */ +typedef void (*GhosttyClipboardWriteReplyFn)( + const GhosttyClipboardWrite* write, + const GhosttyClipboardWriteReply* reply); + +/** + * A synchronous request to write clipboard contents. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. The request, contents array, MIME strings, and + * data strings are all borrowed and valid only for the callback duration. + * + * All entries in `contents` are representations of the same logical value + * and must be committed atomically. A `contents_len` of zero requests that + * the destination be cleared. This is distinct from a content entry whose data + * has zero length. + * + * The write is answered by calling `reply` with this request and a + * GhosttyClipboardWriteReply. This must happen within the clipboard write + * request callback. This struct is only valid during that time. Calling + * `reply` more than once is safely ignored. Returning without replying + * denies the write. + * + * @ingroup terminal + */ +struct GhosttyClipboardWrite { + /** Size of this struct in bytes. */ + size_t size; + + /** Clipboard destination. */ + GhosttyClipboardLocation location; + + /** Borrowed array of MIME representations. */ + const GhosttyClipboardContent* contents; + + /** Number of entries in contents; zero means clear the destination. */ + size_t contents_len; + + /** + * Name of the writing 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 + * The embedder should skip any permission prompt and perform the write. + */ + bool granted; + + /** + * True if the program supplied a session password, so the embedder may + * offer to remember the user's decision through + * GhosttyClipboardWriteReply::remember. When false, remember is ignored. + */ + bool can_remember; + + /** Terminal-owned reply state. Do not access. */ + const void* ctx; + + /** Answer the write; see the struct documentation. */ + GhosttyClipboardWriteReplyFn reply; +}; + /** * Callback function type for clipboard_write. * - * 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, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes - * therefore use the same callback shape. + * The embedder may ask for permission to write or perform the write + * async, but the callback itself is synchronous and the reply function + * must be called during the lifetime of this function. While this callback + * is active the VT stream is paused. * - * 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. + * Answer by calling `write->reply(write, &reply)` before returning. See + * GhosttyClipboardWrite for the full contract. + * + * The request may carry an optional program name requesting the write + * and the state of prior permission granted. If `can_remember` is set + * the response may set the `remember` flag and future requests from this + * same program will be "granted" and the embedder can skip permission + * requests. * * Clipboard read requests (OSC 52 "?" and OSC 5522 reads) are delivered * to GhosttyTerminalClipboardReadFn instead. @@ -544,11 +615,10 @@ typedef enum GHOSTTY_ENUM_TYPED { * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA * @param write Borrowed atomic clipboard write request - * @return The result of attempting the clipboard write * * @ingroup terminal */ -typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( +typedef void (*GhosttyTerminalClipboardWriteFn)( GhosttyTerminal terminal, void* userdata, const GhosttyClipboardWrite* write); @@ -558,7 +628,7 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( * * @ingroup terminal */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** The clipboard was read; the reply carries its contents. */ GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS = 0, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 34f605552..7a05a37bf 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -129,6 +129,8 @@ pub const ClipboardContent = extern struct { }; /// A protocol-neutral request to replace or clear clipboard contents. +/// The embedder answers by calling `reply` with the request before the +/// callback returns. /// /// C: GhosttyClipboardWrite pub const ClipboardWrite = extern struct { @@ -136,8 +138,28 @@ pub const ClipboardWrite = extern struct { location: clipboard.Location, contents: ?[*]const ClipboardContent, contents_len: usize, + name: lib.String, + granted: bool, + can_remember: bool, + /// Terminal-owned reply state; opaque to the embedder. + ctx: *const anyopaque, + reply: ClipboardWriteReplyFn, }; +/// The reply to a clipboard write request. +/// +/// C: GhosttyClipboardWriteReply +pub const ClipboardWriteReply = extern struct { + size: usize, + result: clipboard.Write.Status, + remember: bool, +}; + +/// C function pointer type for replying to a clipboard write. +/// +/// C: GhosttyClipboardWriteReplyFn +pub const ClipboardWriteReplyFn = *const fn (*const ClipboardWrite, *const ClipboardWriteReply) callconv(lib.calling_conv) void; + /// The reply to a clipboard read request. /// /// C: GhosttyClipboardReadReply @@ -293,8 +315,9 @@ const Effects = struct { pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; /// C function pointer type for the clipboard_write callback. The request - /// 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; + /// is borrowed for the callback duration and must be answered through + /// its reply function before the callback returns. + pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) void; /// C function pointer type for the clipboard_read callback. The request /// is borrowed for the callback duration and must be answered through @@ -365,9 +388,15 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } - fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult { + /// Opaque context behind ClipboardWrite.ctx for the reply trampoline. + const ClipboardWriteCtx = struct { + write: clipboard.Write, + }; + + fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) void { const wrapper = TerminalWrapper.fromHandler(handler); - const func = wrapper.effects.clipboard_write orelse return .unsupported; + const func = wrapper.effects.clipboard_write orelse + return write.reply(.unsupported); // Most protocols currently produce one representation, so keep that // path allocation-free while supporting arbitrary multi-MIME writes. @@ -376,7 +405,7 @@ const Effects = struct { stack_contents[0..write.contents.len] else wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch - return .io_error; + return write.reply(.io_error); defer if (write.contents.len > stack_contents.len) wrapper.terminal.gpa().free(contents); @@ -393,13 +422,35 @@ const Effects = struct { }; } + const ctx: ClipboardWriteCtx = .{ .write = write }; const request: ClipboardWrite = .{ .size = @sizeOf(ClipboardWrite), .location = write.location, .contents = if (contents.len > 0) contents.ptr else null, .contents_len = contents.len, + .name = .init(write.name), + .granted = write.granted, + .can_remember = write.can_remember, + .ctx = &ctx, + .reply = &clipboardWriteReplyTrampoline, }; - return func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + + fn clipboardWriteReplyTrampoline( + request: *const ClipboardWrite, + reply: *const ClipboardWriteReply, + ) callconv(lib.calling_conv) void { + const ctx: *const ClipboardWriteCtx = @ptrCast(@alignCast(request.ctx)); + const write = ctx.write; + switch (reply.result) { + .success => write.reply(.{ .success = .{ .remember = reply.remember } }), + .denied => write.reply(.denied), + .busy => write.reply(.busy), + .invalid_data => write.reply(.invalid_data), + .io_error => write.reply(.io_error), + .unsupported, _ => write.reply(.unsupported), + } } /// Opaque context behind ClipboardRead.ctx for the reply trampoline. @@ -4607,13 +4658,16 @@ test "set clipboard_write callback" { var last_mime_lens: [8]usize = @splat(0); var last_data: [8][64]u8 = undefined; var last_data_lens: [8]usize = @splat(0); - var next_result: clipboard.WriteResult = .success; + var last_name_len: usize = 0; + var last_granted: bool = true; + var last_can_remember: bool = true; + var next_result: clipboard.Write.Status = .success; fn clipboardWrite( terminal_: Terminal, ud: ?*anyopaque, request: *const ClipboardWrite, - ) callconv(lib.calling_conv) clipboard.WriteResult { + ) callconv(lib.calling_conv) void { count += 1; last_terminal = terminal_; last_userdata = ud; @@ -4621,6 +4675,9 @@ test "set clipboard_write callback" { last_location = request.location; last_contents_null = request.contents == null; last_contents_len = request.contents_len; + last_name_len = request.name.len; + last_granted = request.granted; + last_can_remember = request.can_remember; if (request.contents) |ptr| { for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| { @@ -4638,7 +4695,11 @@ test "set clipboard_write callback" { } } - return next_result; + request.reply(request, &.{ + .size = @sizeOf(ClipboardWriteReply), + .result = next_result, + .remember = false, + }); } }; S.count = 0; @@ -4665,6 +4726,11 @@ test "set clipboard_write callback" { try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]); + // OSC 52 carries no program identity or password grant state. + try testing.expectEqual(@as(usize, 0), S.last_name_len); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + // OSC 52 destinations are normalized rather than exposed as wire bytes. const location_cases = [_]struct { selector: u8, @@ -4704,8 +4770,9 @@ test "set clipboard_write callback" { try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]); - // Every representation is converted, and callback results propagate - // through the C trampoline for protocols that can acknowledge writes. + // Every representation is converted, and callback replies propagate + // back through the C trampoline for protocols that can acknowledge + // writes. const internal_contents = [_]clipboard.Content{ .{ .mime = "text/plain", .data = "plain" }, .{ .mime = "application/octet-stream", .data = "a\x00b" }, @@ -4713,13 +4780,27 @@ test "set clipboard_write callback" { .{ .mime = "text/rtf", .data = "{\\rtf1 plain}" }, .{ .mime = "image/png", .data = "\x89PNG" }, }; + const Reply = struct { + var last: ?clipboard.Write.Result = null; + fn reply(_: *anyopaque, result: clipboard.Write.Result) void { + last = result; + } + }; S.next_result = .busy; const handler = &t.?.stream.handler; - const write_result = handler.effects.clipboard_write.?(handler, .{ + handler.effects.clipboard_write.?(handler, .{ .location = .primary, .contents = &internal_contents, + .name = "app", + .granted = true, + .can_remember = true, + .reply_ctx = handler, + .reply_fn = &Reply.reply, }); - try testing.expectEqual(clipboard.WriteResult.busy, write_result); + try testing.expect(Reply.last.? == .busy); + try testing.expectEqual(@as(usize, 3), S.last_name_len); + try testing.expect(S.last_granted); + try testing.expect(S.last_can_remember); try testing.expectEqual(@as(usize, 7), S.count); try testing.expectEqual(@as(usize, 5), S.last_contents_len); try testing.expectEqualStrings( @@ -4779,6 +4860,9 @@ test "kitty clipboard write via C effects" { var last_mime_lens: [4]usize = @splat(0); var last_data: [4][64]u8 = undefined; var last_data_lens: [4]usize = @splat(0); + var last_granted: bool = true; + var last_can_remember: bool = true; + var next_remember: bool = false; fn writePty( _: Terminal, @@ -4794,7 +4878,7 @@ test "kitty clipboard write via C effects" { _: Terminal, _: ?*anyopaque, request: *const ClipboardWrite, - ) callconv(lib.calling_conv) clipboard.WriteResult { + ) callconv(lib.calling_conv) void { write_count += 1; last_location = request.location; last_contents_len = request.contents_len; @@ -4812,13 +4896,22 @@ test "kitty clipboard write via C effects" { ); } } - return .success; + last_granted = request.granted; + last_can_remember = request.can_remember; + request.reply(request, &.{ + .size = @sizeOf(ClipboardWriteReply), + .result = .success, + .remember = next_remember, + }); } }; S.responses_len = 0; S.write_count = 0; S.last_mime_lens = @splat(0); S.last_data_lens = @splat(0); + S.last_granted = true; + S.last_can_remember = true; + S.next_remember = false; try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); @@ -4846,12 +4939,36 @@ test "kitty clipboard write via C effects" { "\x1B]5522;type=write:status=DONE:id=c1\x1B\\", S.responses[0..S.responses_len], ); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + + // Password grants round-trip through the C reply: the first pw'd + // commit isn't granted and asks to remember, so the next one + // arrives granted. + S.responses_len = 0; + S.next_remember = true; + const grant_seqs = [_][]const u8{ + "\x1B]5522;type=write:id=g1:pw=c2VjcmV0:name=YXBw\x1B\\", + "\x1B]5522;type=wdata\x1B\\", + "\x1B]5522;type=write:id=g2:pw=c2VjcmV0:name=YXBw\x1B\\", + "\x1B]5522;type=wdata\x1B\\", + }; + for (grant_seqs) |seq| vt_write(t, seq.ptr, seq.len); + try testing.expectEqual(@as(usize, 3), S.write_count); + try testing.expect(S.last_granted); + try testing.expect(S.last_can_remember); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=g1\x1B\\" ++ + "\x1B]5522;type=write:status=DONE:id=g2\x1B\\", + S.responses[0..S.responses_len], + ); + S.next_remember = false; // 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); - try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 3), S.write_count); try testing.expectEqualStrings( "\x1B]5522;type=read:status=EPERM:id=r1\x1B\\", S.responses[0..S.responses_len], diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index b7998bb6c..c1f524a86 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -178,6 +178,7 @@ const type_decls = [_]TypeDecl{ .initStruct("GhosttyClipboardRead", terminal.ClipboardRead), .initStruct("GhosttyClipboardReadReply", terminal.ClipboardReadReply), .initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite), + .initStruct("GhosttyClipboardWriteReply", terminal.ClipboardWriteReply), .initStruct("GhosttyCodepoints", Codepoints), .initStruct("GhosttyColorPaletteMask", color_c.PaletteMask), .initStruct("GhosttyColorRgb", color.RGB.C), @@ -247,7 +248,7 @@ const type_decls = [_]TypeDecl{ .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("GhosttyClipboardWriteResult", clipboard.Write.Status, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"), .initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"), .initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"), .initEnum("GhosttyFormatterFormat", formatter_pkg.Format, "GHOSTTY_FORMATTER_FORMAT_"), diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig index 1c2a26be3..a911c6887 100644 --- a/src/terminal/clipboard.zig +++ b/src/terminal/clipboard.zig @@ -69,24 +69,82 @@ pub const MimeReader = struct { } }; -/// One atomic clipboard write. +/// A request from the running program to write a clipboard. /// -/// Contents are borrowed and only valid for the duration of a clipboard write -/// callback. An empty contents slice clears the destination. +/// Writes 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. Protocols without a write +/// acknowledgement (OSC 52, OSC 1337 Copy) discard the reply. +/// +/// Contents are borrowed and only valid for the duration of the callback. +/// An empty contents slice clears the destination. pub const Write = struct { location: Location, contents: []const Content, -}; -/// The result of a clipboard write. -pub const WriteResult = enum(c_int) { - success = 0, - denied = 1, - unsupported = 2, - busy = 3, - invalid_data = 4, - io_error = 5, - _, + /// Name of the writing 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 perform the write. + 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. + reply_ctx: *anyopaque, + reply_fn: *const fn (*anyopaque, Result) void, + + /// Answer the write. May be called at most once; later calls are + /// ignored. + pub fn reply(self: Write, result: Result) void { + self.reply_fn(self.reply_ctx, result); + } + + /// The status of a clipboard write reply. + pub const Status = enum(c_int) { + success = 0, + denied = 1, + unsupported = 2, + busy = 3, + invalid_data = 4, + io_error = 5, + _, + }; + + /// The reply to a clipboard write. + pub const Result = union(enum) { + /// The write was denied by policy or the user. + denied, + + /// The embedder cannot write this clipboard. + unsupported, + + /// The clipboard is temporarily unavailable. + busy, + + /// One or more representations contain invalid data. + invalid_data, + + /// Writing the clipboard failed. + io_error, + + /// The write succeeded. + success: Success, + + pub const Success = struct { + /// 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, + }; + }; }; /// A request from the running program to read a clipboard. diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 59dc25e56..d01215fa3 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -81,9 +81,8 @@ pub const Handler = struct { 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. + /// recorded when a clipboard_read or clipboard_write reply asks to + /// remember the user's decision. kitty_clipboard_grants: kitty_clipboard.Grants = .{}, /// Called for sequence identifiers not supported by this library. @@ -161,26 +160,8 @@ pub const Handler = struct { /// Called when the running program reports progress via OSC 9;4. progress_report: ?*const fn (*Handler, osc.Command.ProgressReport) void, - /// Called when the running program writes to a clipboard. The write - /// has a normalized destination and one or more decoded MIME - /// representations. All request, MIME, and data memory is borrowed - /// and only valid for the duration of the callback. - /// - /// A write with no contents clears the destination. A content entry - /// with empty data is a distinct empty representation. - /// - /// 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 writes to a clipboard. + clipboard_write: ?*const fn (*Handler, clipboard.Write) void, /// Called when the running program requests clipboard contents /// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522) @@ -661,9 +642,14 @@ pub const Handler = struct { // OSC 52 uses an empty payload to clear the selected clipboard. if (data.len == 0) { - _ = func(self, .{ + func(self, .{ .location = location, .contents = &.{}, + .name = "", + .granted = false, + .can_remember = false, + .reply_ctx = self, + .reply_fn = &ignoreWriteReply, }); return; } @@ -680,12 +666,22 @@ pub const Handler = struct { .mime = "text/plain", .data = decoded, }}; - _ = func(self, .{ + func(self, .{ .location = location, .contents = &contents, + .name = "", + .granted = false, + .can_remember = false, + .reply_ctx = self, + .reply_fn = &ignoreWriteReply, }); } + /// Reply target for clipboard writes on protocols without a write + /// acknowledgement (OSC 52, OSC 1337 Copy): the reply is accepted + /// and discarded. + fn ignoreWriteReply(_: *anyopaque, _: clipboard.Write.Result) void {} + fn clipboardRead( self: *Handler, location: clipboard.Location, @@ -1094,28 +1090,87 @@ pub const Handler = struct { }; 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; + // The effect can't be null here (checked when the transaction + // began) but if an embedder cleared it mid-transaction that's + // ENOSYS. + const func = self.effects.clipboard_write orelse { + self.kittyClipboardFinish(state, .ENOSYS, terminator); + return; + }; - self.kittyClipboardFinish(state, switch (result) { - .success => .DONE, - .denied => .EPERM, - .unsupported => .ENOSYS, - .busy => .EBUSY, - .invalid_data => .EINVAL, - .io_error, _ => .EIO, - }, terminator); + // 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 (committed.name.len > 0) committed.pw else ""; + const granted = self.kitty_clipboard_grants.use(alloc, pw, .write); + + var reply_state: KittyClipboardWriteReplyState = .{ + .handler = self, + .pw = pw, + }; + func(self, .{ + .location = committed.loc, + .contents = committed.contents, + .name = committed.name, + .granted = granted, + .can_remember = pw.len > 0, + .reply_ctx = &reply_state, + .reply_fn = &KittyClipboardWriteReplyState.reply, + }); + + // The program is waiting on the commit status, so a callback + // that returned without a reply is answered as a denial rather + // than silence. + self.kittyClipboardFinish( + state, + reply_state.status orelse .EPERM, + terminator, + ); } + /// Reply state for one synchronous Kitty clipboard write. This lives + /// on the kittyClipboardCommit stack frame, so it is only valid + /// during the callback. + const KittyClipboardWriteReplyState = struct { + handler: *Handler, + + /// The effective password, empty when the request had none. + pw: []const u8, + + /// The replied commit status, mapped 1:1 from the reply result; + /// null until the callback replies. + status: ?kitty_clipboard.Status = null, + + fn reply(ctx: *anyopaque, result: clipboard.Write.Result) void { + const self: *KittyClipboardWriteReplyState = @ptrCast(@alignCast(ctx)); + if (self.status != null) { + log.warn("clipboard write replied more than once, ignoring", .{}); + return; + } + self.status = switch (result) { + .denied => .EPERM, + .unsupported => .ENOSYS, + .busy => .EBUSY, + .invalid_data => .EINVAL, + .io_error => .EIO, + .success => |success| status: { + // 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, + .write, + false, + ) catch |err| { + log.warn("error recording clipboard grant err={}", .{err}); + }; + } + break :status .DONE; + }, + }; + } + }; + /// 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. @@ -3242,7 +3297,7 @@ test "clipboard_write effect callback" { const S = struct { var count: usize = 0; - var result: clipboard.WriteResult = .success; + var result: clipboard.Write.Result = .{ .success = .{} }; var last_location: clipboard.Location = .standard; var last_contents_len: usize = 0; var last_mime: ?[]u8 = null; @@ -3256,7 +3311,7 @@ test "clipboard_write effect callback" { last_contents_len = 0; } - fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult { + fn clipboardWrite(_: *Handler, write: clipboard.Write) void { clearCapture(); count += 1; last_location = write.location; @@ -3267,7 +3322,7 @@ test "clipboard_write effect callback" { last_data = testing.allocator.dupe(u8, write.contents[0].data) catch @panic("failed to capture clipboard data"); } - return result; + write.reply(result); } }; S.count = 0; @@ -3333,9 +3388,9 @@ test "clipboard_write effect callback" { try testing.expectEqualStrings("text/plain", S.last_mime.?); try testing.expectEqualStrings("fragmented", S.last_data.?); - // Callback results are intentionally ignored for protocols without a - // write acknowledgement. The denied result above did not stop later writes. - try testing.expectEqual(clipboard.WriteResult.denied, S.result); + // Reply results are intentionally ignored for protocols without a + // write acknowledgement. The denied reply above did not stop later writes. + try testing.expect(S.result == .denied); } test "clipboard_read effect callback" { @@ -3459,9 +3514,9 @@ test "clipboard_write allocation failure is ignored" { const S = struct { var count: usize = 0; - fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult { + fn clipboardWrite(_: *Handler, write: clipboard.Write) void { count += 1; - return .success; + write.reply(.{ .success = .{} }); } }; S.count = 0; @@ -3489,14 +3544,21 @@ test "clipboard_write allocation failure is ignored" { const KittyClipboardCapture = struct { var responses: [1024]u8 = undefined; var responses_len: usize = 0; + + // Write capture. A null write_result returns without replying. var write_count: usize = 0; - var result: clipboard.WriteResult = .success; + var write_result: ?clipboard.Write.Result = .{ .success = .{} }; + var write_reply_twice: bool = false; 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); + var last_write_name: [64]u8 = undefined; + var last_write_name_len: usize = 0; + var last_write_granted: bool = false; + var last_write_can_remember: bool = false; // Read capture. A null read_result returns without replying. var read_count: usize = 0; @@ -3515,11 +3577,15 @@ const KittyClipboardCapture = struct { fn reset() void { responses_len = 0; write_count = 0; - result = .success; + write_result = .{ .success = .{} }; + write_reply_twice = false; last_location = .standard; last_contents_len = 0; last_mime_lens = @splat(0); last_data_lens = @splat(0); + last_write_name_len = 0; + last_write_granted = false; + last_write_can_remember = false; read_count = 0; read_result = null; read_reply_twice = false; @@ -3537,7 +3603,7 @@ const KittyClipboardCapture = struct { responses_len += data.len; } - fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult { + fn clipboardWrite(_: *Handler, write: clipboard.Write) void { write_count += 1; last_location = write.location; last_contents_len = write.contents.len; @@ -3547,7 +3613,12 @@ const KittyClipboardCapture = struct { last_data_lens[i] = content.data.len; @memcpy(last_data[i][0..content.data.len], content.data); } - return result; + last_write_name_len = write.name.len; + @memcpy(last_write_name[0..write.name.len], write.name); + last_write_granted = write.granted; + last_write_can_remember = write.can_remember; + if (write_result) |r| write.reply(r); + if (write_reply_twice) write.reply(.io_error); } fn clipboardRead(_: *Handler, read: clipboard.Read) void { @@ -3579,6 +3650,10 @@ const KittyClipboardCapture = struct { return last_read_name[0..last_read_name_len]; } + fn writeName() []const u8 { + return last_write_name[0..last_write_name_len]; + } + fn mimeAt(i: usize) []const u8 { return last_mimes[i][0..last_mime_lens[i]]; } @@ -3647,20 +3722,22 @@ test "kitty clipboard write result maps to response status" { defer s.deinit(); const cases = [_]struct { - result: clipboard.WriteResult, + result: ?clipboard.Write.Result, response: []const u8, }{ - .{ .result = .success, .response = "\x1B]5522;type=write:status=DONE\x1B\\" }, + .{ .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\\" }, + // No reply at all is a denial rather than silence. + .{ .result = null, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" }, }; for (cases) |case| { S.reset(); - S.result = case.result; + S.write_result = case.result; // An immediately-committed write with no data is a clear. s.nextSlice("\x1B]5522;type=write\x1B\\"); @@ -3670,6 +3747,16 @@ test "kitty clipboard write result maps to response status" { try testing.expectEqualStrings(case.response, S.responseSlice()); } + // A second reply is ignored. + S.reset(); + S.write_reply_twice = true; + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE\x1B\\", + S.responseSlice(), + ); + // The response echoes the request terminator, unlike kitty which // always uses ST. S.reset(); @@ -3948,6 +4035,70 @@ test "kitty clipboard read password grants" { // the leak otherwise). } +test "kitty clipboard write 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_write = &S.clipboardWrite; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // pw="secret", name="app": the first commit isn't granted but the + // reply may ask to remember it. + S.write_result = .{ .success = .{ .remember = true } }; + s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings("app", S.writeName()); + try testing.expect(!S.last_write_granted); + try testing.expect(S.last_write_can_remember); + + // The same password is now granted; a different one is not. + S.write_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expect(S.last_write_granted); + s.nextSlice("\x1B]5522;type=write:pw=b3RoZXI=:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expect(!S.last_write_granted); + try testing.expect(S.last_write_can_remember); + + // Directions are independent: a write grant doesn't satisfy reads. + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\"); + try testing.expect(!S.last_read_granted); + + // A password without a name doesn't count: it is neither granted + // nor rememberable, even if the reply asks. + S.write_result = .{ .success = .{ .remember = true } }; + s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqualStrings("", S.writeName()); + try testing.expect(!S.last_write_granted); + try testing.expect(!S.last_write_can_remember); + + // A grant is advisory: the request is still forwarded and the + // embedder may deny it. + S.responses_len = 0; + S.write_result = .denied; + s.nextSlice("\x1B]5522;type=write:id=d:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expect(S.last_write_granted); + try testing.expectEqualStrings( + "\x1B]5522;type=write: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); @@ -5185,10 +5336,10 @@ test "continuation reconstructs standard stream without duplicate effects" { fn clipboardWrite( _: *Handler, - _: clipboard.Write, - ) clipboard.WriteResult { + write: clipboard.Write, + ) void { clipboard_count += 1; - return .success; + write.reply(.{ .success = .{} }); } fn reset() void {