macos: implement Kitty clipboard protocol writes

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.
This commit is contained in:
Mitchell Hashimoto
2026-08-24 12:16:49 -07:00
parent c8554f28e0
commit 25c61e852f
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,