macos: implement Kitty clipboard protocol writes (#13998)

Programs can now write the system clipboard through the Kitty clipboard
protocol in the macOS app. This also does all the hard work plumbing
through core termio/apprt so GTK should be an easy follow.

This functionality lets clients copy arbitrary representations (images,
HTML, etc.) into the clipboard. Writes honor `clipboard-write`: allow
applies silently, deny answers EPERM up front before any data is used,
and ask shows the standard confirmation prompt.

After this, I believe the core and macOS have 100% Kitty clipboard
implementation but I'll double check after this.

## Demo



https://github.com/user-attachments/assets/71234fa0-f539-48eb-a633-8dea3addddd5
This commit is contained in:
Mitchell Hashimoto
2026-08-24 12:32:02 -07:00
committed by GitHub
14 changed files with 569 additions and 62 deletions

View File

@@ -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;

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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 = .{

View File

@@ -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,
},

View File

@@ -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."));
},

View File

@@ -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"),
};
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 => {},
}
}

View File

@@ -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,