diff --git a/include/ghostty.h b/include/ghostty.h index fb0e69ce0..cd91368e4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -113,6 +113,7 @@ typedef enum { GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, GHOSTTY_CLIPBOARD_REQUEST_LIST, } ghostty_clipboard_request_e; diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index cfab6c9b5..10efc7122 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -47,7 +47,7 @@ class ClipboardConfirmationController: NSWindowController { switch confirmation.kind { case .paste: window.title = "Warning: Potentially Unsafe Paste" - case .osc_52_read, .osc_52_write, .kitty_read: + case .osc_52_read, .osc_52_write, .kitty_read, .kitty_write: window.title = "Authorize Clipboard Access" } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 101cf5ac8..096cda780 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -15,11 +15,13 @@ struct ClipboardConfirmationView: View { switch (action, reason) { case (.cancel, .paste): return "Cancel" - case (.cancel, .osc_52_read), (.cancel, .osc_52_write), (.cancel, .kitty_read): + case (.cancel, .osc_52_read), (.cancel, .osc_52_write), + (.cancel, .kitty_read), (.cancel, .kitty_write): return "Deny" case (.confirm, .paste): return "Paste" - case (.confirm, .osc_52_read), (.confirm, .osc_52_write), (.confirm, .kitty_read): + case (.confirm, .osc_52_read), (.confirm, .osc_52_write), + (.confirm, .kitty_read), (.confirm, .kitty_write): return "Allow" } } diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index ee31b179f..1e5c53397 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -18,6 +18,10 @@ extension Ghostty { /// the Kitty clipboard protocol (OSC 5522). case kitty_read + /// An application is attempting to write to the clipboard using + /// the Kitty clipboard protocol (OSC 5522). + case kitty_write + /// The text to show in the clipboard confirmation prompt for this /// request. The name is the requesting program's human friendly /// name, when the protocol carries one. @@ -33,7 +37,7 @@ extension Ghostty { \(program) is attempting to read from the clipboard. The current clipboard contents are shown below. """ - case .osc_52_write: + case .osc_52_write, .kitty_write: return """ \(program) is attempting to write to the clipboard. The content to write is shown below. @@ -51,6 +55,8 @@ extension Ghostty { return .osc_52_write case GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ: return .kitty_read + case GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE: + return .kitty_write default: return nil } diff --git a/src/Surface.zig b/src/Surface.zig index 8c42b5445..b9a31e658 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1063,6 +1063,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .kitty_clipboard_read => |req| try self.kittyClipboardRead(req), + .kitty_clipboard_write => |req| try self.kittyClipboardWrite(req), + .clipboard_write => |w| switch (w.req) { .small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type), .stable => |v| try self.clipboardWrite(v, w.clipboard_type), @@ -5947,7 +5949,7 @@ pub fn completeClipboardRequest( // the IO thread. if (complete.remember and kitty.pw.len > 0) { const pw = try self.alloc.dupe(u8, kitty.pw); - self.queueIo(.{ .kitty_clipboard_grant = .{ + self.queueIo(.{ .kitty_clipboard_grant_read = .{ .alloc = self.alloc, .pw = pw, } }, .unlocked); @@ -5959,6 +5961,65 @@ pub fn completeClipboardRequest( complete.available, ); }, + + .kitty_write => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt. + if (self.config.clipboard_write == .ask and + !complete.confirmed and + !kitty.granted) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_write = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + // Apply the committed representations carried by the + // request itself; any contents echoed back by the apprt + // are only what its confirmation prompt displayed. An + // empty commit clears the clipboard, which the apprt + // write API expresses as a single empty text entry. + self.rt_surface.setClipboard( + kitty.location, + if (kitty.contents.len > 0) kitty.contents else &.{.{ + .mime = "text/plain", + .data = "", + }}, + false, + ) catch |err| { + log.err("error setting clipboard err={}", .{err}); + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EIO, + ); + return; + }; + + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .DONE, + ); + }, } } @@ -5994,10 +6055,27 @@ pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void { // The Kitty clipboard protocol reports denial explicitly. .kitty_read => |kitty| { defer kitty.destroy(); - self.kittyClipboardReadStatus(kitty, .EPERM) catch |err| { + self.kittyClipboardStatus( + .read, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { log.warn("error replying to kitty clipboard read err={}", .{err}); }; }, + + .kitty_write => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard write err={}", .{err}); + }; + }, } } @@ -6040,11 +6118,13 @@ fn startClipboardRequest( return .unsupported; }, - // The clipboard-read policy was already applied by - // kittyClipboardRead, which owns replying on denial. - .kitty_read => {}, + // The clipboard access policies were already applied by + // kittyClipboardRead and kittyClipboardWrite, which own + // replying on denial. + .kitty_read, .kitty_write => {}, - // No clipboard write code paths travel through this function + // OSC 52 writes don't travel through this function; they go + // straight to the apprt setClipboard API. .osc_52_write => unreachable, } @@ -6243,7 +6323,7 @@ fn kittyClipboardRead( if (self.config.clipboard_read == .deny) { defer req.destroy(); log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}); - try self.kittyClipboardReadStatus(req, .EPERM); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .EPERM); return; } @@ -6252,7 +6332,7 @@ fn kittyClipboardRead( .{ .kitty_read = req }, ) catch |err| { defer req.destroy(); - self.kittyClipboardReadStatus(req, .EIO) catch {}; + self.kittyClipboardStatus(.read, req.id, req.terminator, .EIO) catch {}; return err; }; @@ -6273,24 +6353,68 @@ fn kittyClipboardRead( // unsupported primary selection. .unsupported => { defer req.destroy(); - try self.kittyClipboardReadStatus(req, .ENOSYS); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .ENOSYS); }, } } -/// Reply to a Kitty clipboard read with a single status packet. -fn kittyClipboardReadStatus( +/// Handle a committed Kitty clipboard protocol (OSC 5522) write +/// transaction forwarded by the IO thread. This takes ownership of the +/// request state. +fn kittyClipboardWrite( self: *Surface, - req: *const apprt.ClipboardRequest.KittyRead, - status: terminal.kitty.clipboard.Status, + req: *apprt.ClipboardRequest.KittyWrite, ) !void { + // A write denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + // The IO thread already fails transactions that begin under a + // deny policy, but the policy may have changed mid-transaction. + if (self.config.clipboard_write == .deny) { + defer req.destroy(); + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_write = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.write, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The apprt can't write this clipboard at all, e.g. an + // unsupported primary selection. Writes carry their own + // contents so there is no meaningful unavailable state; treat + // it the same. + .unavailable, .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Reply to a Kitty clipboard request with a single status packet. +fn kittyClipboardStatus( + self: *Surface, + op: terminal.kitty.clipboard.Operation, + id: []const u8, + terminator: terminal.osc.Terminator, + status: terminal.kitty.clipboard.Status, +) error{ OutOfMemory, WriteFailed }!void { var aw: std.Io.Writer.Allocating = .init(self.alloc); defer aw.deinit(); try (terminal.kitty.clipboard.Response{ - .op = .read, + .op = op, .status = status, - .id = req.id, - .terminator = req.terminator, + .id = id, + .terminator = terminator, }).encode(&aw.writer); self.queueIo(.{ .write_alloc = .{ diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 69db394b7..7bab01257 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -711,6 +711,20 @@ pub const Surface = struct { clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, ) !apprt.ClipboardReadResult { + // Kitty clipboard writes carry their own contents and read + // nothing from the clipboard, so they skip the read callback + // entirely: the request is completed immediately, and a + // completion that requires confirmation diverts into the + // apprt confirmation flow just like a read. + if (state == .kitty_write) { + const alloc = self.app.core_app.alloc; + const state_ptr = try alloc.create(apprt.ClipboardRequest); + errdefer alloc.destroy(state_ptr); + state_ptr.* = state; + self.completeKittyClipboardWrite(state_ptr); + return .started; + } + // The representations the read wants served. Text-only // requesters ask for the canonical text type; Kitty clipboard // reads ask for exactly what the program requested, with @@ -735,6 +749,9 @@ pub const Surface = struct { break :mimes mimes_buf[0..kitty.mimes.len]; }, + // Handled above. + .kitty_write => unreachable, + // No clipboard write code paths travel through this function .osc_52_write => unreachable, }; @@ -769,6 +786,68 @@ pub const Surface = struct { return result; } + /// Complete a Kitty clipboard protocol write request. The apprt + /// contributes nothing to the completion (the authoritative + /// contents live in the request itself), but a completion that + /// requires confirmation diverts into the confirm callback, which + /// keeps the state alive for the asynchronous prompt. + fn completeKittyClipboardWrite( + self: *Surface, + state: *apprt.ClipboardRequest, + ) void { + const alloc = self.app.core_app.alloc; + const kitty = state.kitty_write; + + self.core_surface.completeClipboardRequest( + state.*, + .{}, + ) catch |err| switch (err) { + error.UnauthorizedPaste => { + // Convert the would-be contents so the permission + // prompt can display exactly what would be written. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + const contents = conv_alloc.alloc( + CAPI.ClipboardContent, + kitty.contents.len, + ) catch |alloc_err| { + log.err("error confirming clipboard request err={}", .{alloc_err}); + self.core_surface.denyClipboardRequest(state.*); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(contents); + for (kitty.contents, contents) |src, *dst| dst.* = .{ + .mime = src.mime, + .data = src.data.ptr, + .len = src.data.len, + }; + + self.app.opts.confirm_read_clipboard( + self.userdata, + &.{ + .contents = contents.ptr, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .name = if (kitty.name.len > 0) kitty.name.ptr else null, + .can_remember = kitty.pw.len > 0, + }, + state, + state.*, + ); + + return; + }, + + else => log.err("error completing clipboard request err={}", .{err}), + }; + + // We don't defer this because the clipboard confirmation route + // preserves the clipboard request. + alloc.destroy(state); + } + fn completeClipboardRequest( self: *Surface, complete: *const CAPI.ClipboardComplete, @@ -826,7 +905,7 @@ pub const Surface = struct { // Session grant information for the permission prompt, // carried only by Kitty clipboard protocol requests. const name: ?[*:0]const u8, const can_remember: bool = switch (state.*) { - .kitty_read => |kitty| .{ + inline .kitty_read, .kitty_write => |kitty| .{ if (kitty.name.len > 0) kitty.name.ptr else null, kitty.pw.len > 0, }, diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index 61b0b9faa..d993b55c9 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -193,7 +193,7 @@ pub const ClipboardConfirmationDialog = extern struct { const priv = self.private(); const req = priv.request orelse return; switch (req.*) { - .osc_52_write => { + .osc_52_write, .kitty_write => { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below.")); }, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index b81e8b8bb..e6cfe5c50 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -4119,9 +4119,11 @@ const Clipboard = struct { clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, ) Allocator.Error!apprt.ClipboardReadResult { - // The GTK apprt doesn't support Kitty clipboard protocol reads + // The GTK apprt doesn't support the Kitty clipboard protocol // yet. - if (state == .kitty_read or state == .list) return .unsupported; + if (state == .kitty_read or + state == .kitty_write or + state == .list) return .unsupported; // Get our requested clipboard const clipboard = get( @@ -4224,7 +4226,7 @@ const Clipboard = struct { .request = &req, .@"can-remember" = switch (req) { .osc_52_read, .osc_52_write => true, - .paste, .list, .kitty_read => false, + .paste, .list, .kitty_read, .kitty_write => false, }, .@"clipboard-contents" = contents_buf, }, @@ -4261,7 +4263,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .allow, .osc_52_write => surface.config.clipboard_write = .allow, - .paste, .list, .kitty_read => {}, + .paste, .list, .kitty_read, .kitty_write => {}, }; // Get our text @@ -4299,7 +4301,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .deny, .osc_52_write => surface.config.clipboard_write = .deny, - .paste, .list, .kitty_read => @panic("request should not be able to be remembered"), + .paste, .list, .kitty_read, .kitty_write => @panic("request should not be able to be remembered"), }; } diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 37dba17c5..d8510fb84 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -70,6 +70,7 @@ pub const ClipboardRequestType = enum(u8) { osc_52_read, osc_52_write, kitty_read, + kitty_write, list, }; @@ -108,6 +109,10 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// protocol (OSC 5522). kitty_read: *KittyRead, + /// A request to write clipboard contents via the Kitty clipboard + /// protocol (OSC 5522), carrying a fully committed transaction. + kitty_write: *KittyWrite, + /// A request to list the available clipboard MIME types without /// reading any of their data. list: Clipboard, @@ -161,6 +166,52 @@ pub const ClipboardRequest = union(ClipboardRequestType) { } }; + /// State for one committed Kitty clipboard protocol write + /// transaction. Like KittyRead, this is created on the IO thread + /// and completed on the app thread, so everything, including the + /// struct itself, is allocated from the arena. + pub const KittyWrite = struct { + arena: std.heap.ArenaAllocator, + + /// The clipboard being written. The protocol can only name the + /// standard clipboard or the primary selection. + location: Clipboard, + + /// The committed representations. These are the authoritative + /// contents of the write: completions apply these rather than + /// any contents echoed back by the apprt. The values are + /// sentinel-terminated so they can cross a C apprt boundary + /// without copies; data is binary-safe via its length. + contents: []const ClipboardContent, + + /// The sanitized request id, echoed in every response packet. + id: []const u8, + + /// The effective session password, empty when the request had + /// none. A non-empty password means the user's decision may be + /// remembered as a session grant. + pw: []const u8, + + /// The human friendly name of the requesting program, shown in + /// permission prompts. Empty when absent. Sentinel-terminated + /// so it can cross a C apprt boundary without copies. + name: [:0]const u8, + + /// True when a stored session grant already covers this + /// request, so any permission prompt is skipped. + granted: bool, + + /// The response terminator, matching the request's. + terminator: terminal.osc.Terminator, + + pub fn destroy(self: *KittyWrite) void { + // The struct itself lives in the arena, so move the arena + // out before tearing it down. + var arena = self.arena; + arena.deinit(); + } + }; + /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { .gtk => @import("gobject").ext.defineBoxed( diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index d2ca9171a..51da3374a 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -77,6 +77,11 @@ pub const Message = union(enum) { /// it. kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead, + /// A committed Kitty clipboard protocol (OSC 5522) write + /// transaction. The receiver takes ownership of the request state + /// and must eventually destroy it. + kitty_clipboard_write: *apprt.ClipboardRequest.KittyWrite, + /// Write the clipboard contents. clipboard_write: struct { clipboard_type: apprt.Clipboard, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index ba08ac999..0ac139962 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -719,11 +719,15 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { /// Sends a DSR response for the current color scheme to the pty. /// Record a Kitty clipboard protocol session grant so future requests /// carrying the password skip the permission prompt. -pub fn kittyClipboardGrant(self: *Termio, pw: []const u8) !void { +pub fn kittyClipboardGrant( + self: *Termio, + pw: []const u8, + dir: terminalpkg.kitty.clipboard.Grants.Direction, +) error{OutOfMemory}!void { self.renderer_state.mutex.lockUncancelable(global.io()); defer self.renderer_state.mutex.unlock(global.io()); - try self.terminal_stream.handler.kittyClipboardGrant(pw); + try self.terminal_stream.handler.kittyClipboardGrant(pw, dir); } pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index bb842142d..de268350b 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -336,9 +336,13 @@ fn drainMailbox( } }, .jump_to_prompt => |v| try io.jumpToPrompt(v), - .kitty_clipboard_grant => |v| { + .kitty_clipboard_grant_read => |v| { defer v.alloc.free(v.pw); - try io.kittyClipboardGrant(v.pw); + try io.kittyClipboardGrant(v.pw, .read); + }, + .kitty_clipboard_grant_write => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw, .write); }, .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, diff --git a/src/termio/message.zig b/src/termio/message.zig index f200be4ce..241687409 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -83,12 +83,10 @@ pub const Message = union(enum) { focused: bool, /// Record a Kitty clipboard protocol session grant for a password - /// so future requests carrying it skip the permission prompt. The - /// password is allocated and must be freed. - kitty_clipboard_grant: struct { - alloc: Allocator, - pw: []const u8, - }, + /// so future requests carrying it skip the permission prompt, for + /// reads and writes respectively. + kitty_clipboard_grant_read: KittyClipboardGrant, + kitty_clipboard_grant_write: KittyClipboardGrant, /// Write where the data fits in the union. write_small: WriteReq.Small, @@ -99,6 +97,13 @@ pub const Message = union(enum) { /// Write where the data is allocated and must be freed. write_alloc: WriteReq.Alloc, + /// The payload of the kitty_clipboard_grant_* messages. The + /// password is allocated and must be freed. + pub const KittyClipboardGrant = struct { + alloc: Allocator, + pw: []const u8, + }; + /// Return a write request for the given data. This will use /// write_small if it fits or write_alloc otherwise. This should NOT /// be used for stable pointers which can be manually set to write_stable. @@ -119,7 +124,9 @@ pub const Message = union(enum) { v.alloc.destroy(v.ptr); }, .write_alloc => |v| v.alloc.free(v.data), - .kitty_clipboard_grant => |v| v.alloc.free(v.pw), + .kitty_clipboard_grant_read, + .kitty_clipboard_grant_write, + => |v| v.alloc.free(v.pw), else => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 4609b49e6..bd1cb883a 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -72,6 +72,10 @@ pub const StreamHandler = struct { /// Requests carrying a granted password skip the permission prompt. kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{}, + /// The in-flight Kitty clipboard protocol (OSC 5522) write + /// transaction, if any. + kitty_clipboard_write: ?*terminal.kitty.clipboard.WriteState = null, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -89,6 +93,7 @@ pub const StreamHandler = struct { pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + self.kittyClipboardWriteAbort(); self.kitty_clipboard_grants.deinit(self.alloc); if (comptime tmux_enabled) tmux: { const viewer = self.tmux_viewer orelse break :tmux; @@ -887,8 +892,12 @@ pub const StreamHandler = struct { /// Record a Kitty clipboard protocol session grant so future /// requests with this password skip the permission prompt. - pub fn kittyClipboardGrant(self: *StreamHandler, pw: []const u8) !void { - try self.kitty_clipboard_grants.grant(self.alloc, pw, .read, false); + pub fn kittyClipboardGrant( + self: *StreamHandler, + pw: []const u8, + dir: terminal.kitty.clipboard.Grants.Direction, + ) error{OutOfMemory}!void { + try self.kitty_clipboard_grants.grant(self.alloc, pw, dir, false); } pub fn queryKittyKeyboard(self: *StreamHandler) !void { @@ -1009,7 +1018,7 @@ pub const StreamHandler = struct { fn kittyClipboard( self: *StreamHandler, v: terminal.osc.Command.KittyClipboardProtocol, - ) !void { + ) error{ OutOfMemory, WriteFailed }!void { const kitty_clipboard = terminal.kitty.clipboard; // Decode and validate the metadata. Malformed metadata drops @@ -1028,28 +1037,22 @@ pub const StreamHandler = struct { v.terminator, ), - // Writes aren't implemented in the GUI yet. Failing the - // transaction up front matches a libghostty-vt handler - // without a clipboard_write effect and spares the program - // from waiting on a commit response that never comes. - .write => { - var stream: std.Io.Writer.Allocating = .init(self.alloc); - defer stream.deinit(); - try (kitty_clipboard.Response{ - .op = .write, - .status = .ENOSYS, - .id = meta.id, - .terminator = v.terminator, - }).encode(&stream.writer); - self.messageWriter(.{ .write_alloc = .{ - .alloc = self.alloc, - .data = try stream.toOwnedSlice(), - } }); - }, + .write => try self.kittyClipboardWriteBegin( + &meta, + v.terminator, + ), - // Data packets without an accepted write transaction are - // silently ignored, matching kitty. - .wdata, .walias => {}, + .wdata => try self.kittyClipboardWriteData( + &meta, + v.payload orelse "", + v.terminator, + ), + + .walias => try self.kittyClipboardWriteAlias( + &meta, + v.payload orelse "", + v.terminator, + ), } } @@ -1134,6 +1137,225 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .kitty_clipboard_read = req }); } + /// Begin a Kitty clipboard write transaction (type=write). + fn kittyClipboardWriteBegin( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // A new write silently replaces any in-flight transaction. + self.kittyClipboardWriteAbort(); + + // A write denied by policy 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.clipboard_write == .deny) { + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardWriteStatus( + .EPERM, + meta.id, + terminator, + ); + return; + } + + const state = try self.alloc.create(terminal.kitty.clipboard.WriteState); + errdefer self.alloc.destroy(state); + state.* = try .init(self.alloc, meta); + self.kitty_clipboard_write = state; + } + + /// Accumulate one wdata chunk, or commit the transaction when the + /// chunk carries no MIME type. + fn kittyClipboardWriteData( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // Data without a transaction is silently ignored, matching + // kitty. + 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.kittyClipboardWriteCommit( + state, + terminator, + ); + + state.data( + self.alloc, + meta, + payload, + ) catch |err| switch (err) { + // Failing to spool matches kitty's EIO for a failed buffer write. + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + }; + } + + /// Register aliases from a walias packet. + fn kittyClipboardWriteAlias( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // Aliases without a transaction or without a target MIME type + // are silently ignored, matching kitty. + const state = self.kitty_clipboard_write orelse return; + if (meta.mime.len == 0) return; + + state.alias( + self.alloc, + meta, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // An undecodable alias payload aborts the transaction. + error.Invalid => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + /// Commit the transaction: resolve the final contents and forward + /// them to the surface thread, which owns policy, any permission + /// prompt, the clipboard write itself, and the final reply. + fn kittyClipboardWriteCommit( + self: *StreamHandler, + state: *terminal.kitty.clipboard.WriteState, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + self.kittyClipboardWriteSend( + state, + terminator, + ) catch |err| switch (err) { + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + }; + + // The transaction is complete; the surface owns the reply. + self.kittyClipboardWriteAbort(); + } + + fn kittyClipboardWriteSend( + self: *StreamHandler, + state: *terminal.kitty.clipboard.WriteState, + terminator: terminal.osc.Terminator, + ) error{OutOfMemory}!void { + const committed = try state.commit(self.alloc); + defer committed.deinit(self.alloc); + + // Per the spec a password without a name is no password. A + // stored session grant for it lets the surface skip its + // permission prompt. + const pw: []const u8 = if (committed.name.len > 0) committed.pw else ""; + const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .write); + + // Everything about the request, including the request struct + // itself, lives in a single arena that crosses to the surface + // thread, which owns it from the moment the message is sent. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const req = try alloc.create(apprt.ClipboardRequest.KittyWrite); + const contents = try alloc.alloc( + apprt.ClipboardContent, + committed.contents.len, + ); + for (committed.contents, contents) |src, *dst| dst.* = .{ + .mime = try alloc.dupeZ(u8, src.mime), + .data = try alloc.dupeZ(u8, src.data), + }; + const id = try alloc.dupe(u8, committed.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, committed.name); + req.* = .{ + // The arena must be copied in last so it tracks every + // allocation above. + .arena = arena, + .location = switch (committed.loc) { + .primary => .primary, + else => .standard, + }, + .contents = contents, + .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, + .terminator = terminator, + }; + + self.surfaceMessageWriter(.{ .kitty_clipboard_write = req }); + } + + /// Answer the 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 kittyClipboardWriteFinish( + self: *StreamHandler, + state: *const terminal.kitty.clipboard.WriteState, + status: terminal.kitty.clipboard.Status, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + defer self.kittyClipboardWriteAbort(); + try self.kittyClipboardWriteStatus(status, state.id, terminator); + } + + /// Reply to a write transaction with a single status packet. + fn kittyClipboardWriteStatus( + self: *StreamHandler, + status: terminal.kitty.clipboard.Status, + id: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + var stream: std.Io.Writer.Allocating = .init(self.alloc); + defer stream.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = .write, + .status = status, + .id = id, + .terminator = terminator, + }).encode(&stream.writer); + self.messageWriter(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try stream.toOwnedSlice(), + } }); + } + + /// Drop any in-flight write transaction without responding. + fn kittyClipboardWriteAbort(self: *StreamHandler) void { + if (self.kitty_clipboard_write) |state| { + state.deinit(self.alloc); + self.alloc.destroy(state); + self.kitty_clipboard_write = null; + } + } + fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt,