mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 17:11:40 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
156
src/Surface.zig
156
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 = .{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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."));
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user