mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-27 01:21:39 +00:00
macos: implement Kitty clipboard protocol reads (OSC 5522)
This commit is contained in:
@@ -86,8 +86,16 @@ typedef enum {
|
||||
GHOSTTY_CLIPBOARD_REQUEST_PASTE,
|
||||
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
|
||||
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
|
||||
GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ,
|
||||
} ghostty_clipboard_request_e;
|
||||
|
||||
// apprt.ClipboardReadResult
|
||||
typedef enum {
|
||||
GHOSTTY_CLIPBOARD_READ_STARTED,
|
||||
GHOSTTY_CLIPBOARD_READ_UNAVAILABLE,
|
||||
GHOSTTY_CLIPBOARD_READ_UNSUPPORTED,
|
||||
} ghostty_clipboard_read_result_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_MOUSE_RELEASE,
|
||||
GHOSTTY_MOUSE_PRESS,
|
||||
@@ -1023,9 +1031,10 @@ typedef struct {
|
||||
} ghostty_action_s;
|
||||
|
||||
typedef void (*ghostty_runtime_wakeup_cb)(void*);
|
||||
typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
|
||||
ghostty_clipboard_e,
|
||||
void*);
|
||||
typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)(
|
||||
void*,
|
||||
ghostty_clipboard_e,
|
||||
void*);
|
||||
typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
|
||||
void*,
|
||||
const char*,
|
||||
|
||||
@@ -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:
|
||||
case .osc_52_read, .osc_52_write, .kitty_read:
|
||||
window.title = "Authorize Clipboard Access"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ struct ClipboardConfirmationView: View {
|
||||
switch (action, reason) {
|
||||
case (.cancel, .paste):
|
||||
return "Cancel"
|
||||
case (.cancel, .osc_52_read), (.cancel, .osc_52_write):
|
||||
case (.cancel, .osc_52_read), (.cancel, .osc_52_write), (.cancel, .kitty_read):
|
||||
return "Deny"
|
||||
case (.confirm, .paste):
|
||||
return "Paste"
|
||||
case (.confirm, .osc_52_read), (.confirm, .osc_52_write):
|
||||
case (.confirm, .osc_52_read), (.confirm, .osc_52_write), (.confirm, .kitty_read):
|
||||
return "Allow"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,19 +287,24 @@ extension Ghostty {
|
||||
_ userdata: UnsafeMutableRawPointer?,
|
||||
location: ghostty_clipboard_e,
|
||||
state: UnsafeMutableRawPointer?
|
||||
) -> Bool {
|
||||
) -> ghostty_clipboard_read_result_e {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let surface = surfaceView.surface else { return false }
|
||||
guard let surface = surfaceView.surface else {
|
||||
return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED
|
||||
}
|
||||
|
||||
// Get our pasteboard
|
||||
guard let pasteboard = NSPasteboard.ghostty(location) else { return false }
|
||||
guard let pasteboard = NSPasteboard.ghostty(location) else {
|
||||
return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED
|
||||
}
|
||||
|
||||
// Return false if there is no text-like clipboard content so
|
||||
// performable paste bindings can pass through to the terminal.
|
||||
guard let str = pasteboard.getOpinionatedStringContents() else { return false }
|
||||
// We can only serve text-like clipboard contents.
|
||||
guard let str = pasteboard.getOpinionatedStringContents() else {
|
||||
return GHOSTTY_CLIPBOARD_READ_UNAVAILABLE
|
||||
}
|
||||
|
||||
completeClipboardRequest(surface, data: str, state: state)
|
||||
return true
|
||||
return GHOSTTY_CLIPBOARD_READ_STARTED
|
||||
}
|
||||
|
||||
static func confirmReadClipboard(
|
||||
@@ -325,7 +330,7 @@ extension Ghostty {
|
||||
guard let surface = surfaceView.surface else { return }
|
||||
completeClipboardRequest(
|
||||
surface,
|
||||
data: contents ?? "",
|
||||
data: contents,
|
||||
state: state,
|
||||
confirmed: true)
|
||||
}
|
||||
@@ -334,10 +339,16 @@ extension Ghostty {
|
||||
|
||||
private static func completeClipboardRequest(
|
||||
_ surface: ghostty_surface_t,
|
||||
data: String,
|
||||
data: String?,
|
||||
state: UnsafeMutableRawPointer?,
|
||||
confirmed: Bool = false
|
||||
) {
|
||||
// Nil data denies the request.
|
||||
guard let data else {
|
||||
ghostty_surface_complete_clipboard_request(surface, nil, state, confirmed)
|
||||
return
|
||||
}
|
||||
|
||||
data.withCString { ptr in
|
||||
ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ extension Ghostty {
|
||||
/// An application is attempting to write to the clipboard using OSC 52.
|
||||
case osc_52_write
|
||||
|
||||
/// An application is attempting to read from the clipboard using
|
||||
/// the Kitty clipboard protocol (OSC 5522).
|
||||
case kitty_read
|
||||
|
||||
/// The text to show in the clipboard confirmation prompt for this request.
|
||||
func text() -> String {
|
||||
switch self {
|
||||
@@ -20,7 +24,7 @@ extension Ghostty {
|
||||
return """
|
||||
Pasting this text to the terminal may be dangerous as it looks like some commands may be executed.
|
||||
"""
|
||||
case .osc_52_read:
|
||||
case .osc_52_read, .kitty_read:
|
||||
return """
|
||||
An application is attempting to read from the clipboard.
|
||||
The current clipboard contents are shown below.
|
||||
@@ -41,6 +45,8 @@ extension Ghostty {
|
||||
return .osc_52_read
|
||||
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
|
||||
return .osc_52_write
|
||||
case GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ:
|
||||
return .kitty_read
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
178
src/Surface.zig
178
src/Surface.zig
@@ -1061,6 +1061,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
||||
_ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
|
||||
},
|
||||
|
||||
.kitty_clipboard_read => |req| try self.kittyClipboardRead(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),
|
||||
@@ -5107,15 +5109,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
{},
|
||||
),
|
||||
|
||||
.paste_from_clipboard => return try self.startClipboardRequest(
|
||||
.paste_from_clipboard => return (try self.startClipboardRequest(
|
||||
.standard,
|
||||
.{ .paste = {} },
|
||||
),
|
||||
)) == .started,
|
||||
|
||||
.paste_from_selection => return try self.startClipboardRequest(
|
||||
.paste_from_selection => return (try self.startClipboardRequest(
|
||||
.selection,
|
||||
.{ .paste = {} },
|
||||
),
|
||||
)) == .started,
|
||||
|
||||
.increase_font_size => |delta| {
|
||||
// Max delta is somewhat arbitrary.
|
||||
@@ -5879,20 +5881,64 @@ pub fn completeClipboardRequest(
|
||||
.mime = "text/plain",
|
||||
.data = data,
|
||||
}}, !confirmed),
|
||||
|
||||
.kitty_read => |kitty| {
|
||||
// If we need confirmation we return an error without
|
||||
// consuming the request state; the apprt keeps it alive
|
||||
// for the confirmation flow.
|
||||
if (self.config.clipboard_read == .ask and !confirmed) {
|
||||
return error.UnauthorizedPaste;
|
||||
}
|
||||
|
||||
defer kitty.destroy();
|
||||
try self.completeKittyClipboardRead(kitty, data);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Deny an in-flight clipboard request. This consumes the request: for
|
||||
/// request types whose protocol expects an answer, the denial reply is
|
||||
/// written to the pty.
|
||||
pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void {
|
||||
switch (req) {
|
||||
// A denied paste simply doesn't happen.
|
||||
.paste => {},
|
||||
|
||||
// OSC 52 has no error responses, but the client is waiting on
|
||||
// a reply, so a denied read is answered with empty contents.
|
||||
.osc_52_read => |clipboard| self.completeClipboardReadOSC52(
|
||||
"",
|
||||
clipboard,
|
||||
true,
|
||||
) catch |err| {
|
||||
log.warn("error replying to OSC 52 clipboard read err={}", .{err});
|
||||
},
|
||||
|
||||
// A denied write simply doesn't happen.
|
||||
.osc_52_write => {},
|
||||
|
||||
// The Kitty clipboard protocol reports denial explicitly.
|
||||
.kitty_read => |kitty| {
|
||||
defer kitty.destroy();
|
||||
self.kittyClipboardReadStatus(kitty, .EPERM) catch |err| {
|
||||
log.warn("error replying to kitty clipboard read err={}", .{err});
|
||||
};
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// This starts a clipboard request, with some basic validation. For example,
|
||||
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
|
||||
///
|
||||
/// Returns true if the request was started, false if it was not (e.g., clipboard
|
||||
/// doesn't contain text for paste requests). This allows performable keybinds
|
||||
/// to pass through when the action cannot be performed.
|
||||
/// The result reports whether the request was started; requests that
|
||||
/// weren't started never complete. Callers own reacting to that, e.g.
|
||||
/// performable paste keybinds pass through and Kitty clipboard reads
|
||||
/// answer the program.
|
||||
fn startClipboardRequest(
|
||||
self: *Surface,
|
||||
loc: apprt.Clipboard,
|
||||
req: apprt.ClipboardRequest,
|
||||
) !bool {
|
||||
) !apprt.ClipboardReadResult {
|
||||
switch (req) {
|
||||
.paste => {}, // always allowed
|
||||
.osc_52_read => if (self.config.clipboard_read == .deny) {
|
||||
@@ -5900,9 +5946,13 @@ fn startClipboardRequest(
|
||||
"application attempted to read clipboard, but 'clipboard-read' is set to deny",
|
||||
.{},
|
||||
);
|
||||
return false;
|
||||
return .unsupported;
|
||||
},
|
||||
|
||||
// The clipboard-read policy was already applied by
|
||||
// kittyClipboardRead, which owns replying on denial.
|
||||
.kitty_read => {},
|
||||
|
||||
// No clipboard write code paths travel through this function
|
||||
.osc_52_write => unreachable,
|
||||
}
|
||||
@@ -6034,6 +6084,116 @@ fn completeClipboardReadOSC52(
|
||||
} }, .unlocked);
|
||||
}
|
||||
|
||||
/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded
|
||||
/// by the IO thread. This takes ownership of the request state.
|
||||
fn kittyClipboardRead(
|
||||
self: *Surface,
|
||||
req: *apprt.ClipboardRequest.KittyRead,
|
||||
) !void {
|
||||
// A read denied by policy answers EPERM so clients degrade
|
||||
// gracefully instead of waiting on a response that never comes.
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = self.startClipboardRequest(
|
||||
req.location,
|
||||
.{ .kitty_read = req },
|
||||
) catch |err| {
|
||||
defer req.destroy();
|
||||
self.kittyClipboardReadStatus(req, .EIO) catch {};
|
||||
return err;
|
||||
};
|
||||
|
||||
switch (result) {
|
||||
// The request completes asynchronously.
|
||||
.started => {},
|
||||
|
||||
// The clipboard has nothing we can serve, which is a
|
||||
// successful read that serves no representations. This never
|
||||
// prompts even under an ask policy since there are no contents
|
||||
// to disclose.
|
||||
.unavailable => {
|
||||
defer req.destroy();
|
||||
try self.completeKittyClipboardRead(req, "");
|
||||
},
|
||||
|
||||
// The apprt can't serve this clipboard at all, e.g. an
|
||||
// unsupported primary selection.
|
||||
.unsupported => {
|
||||
defer req.destroy();
|
||||
try self.kittyClipboardReadStatus(req, .ENOSYS);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply to a Kitty clipboard read with a single status packet.
|
||||
fn kittyClipboardReadStatus(
|
||||
self: *Surface,
|
||||
req: *const apprt.ClipboardRequest.KittyRead,
|
||||
status: terminal.kitty.clipboard.Status,
|
||||
) !void {
|
||||
var aw: std.Io.Writer.Allocating = .init(self.alloc);
|
||||
defer aw.deinit();
|
||||
try (terminal.kitty.clipboard.Response{
|
||||
.op = .read,
|
||||
.status = status,
|
||||
.id = req.id,
|
||||
.terminator = req.terminator,
|
||||
}).encode(&aw.writer);
|
||||
|
||||
self.queueIo(.{ .write_alloc = .{
|
||||
.alloc = self.alloc,
|
||||
.data = try aw.toOwnedSlice(),
|
||||
} }, .unlocked);
|
||||
}
|
||||
|
||||
/// Complete a Kitty clipboard protocol read with the clipboard
|
||||
/// contents.
|
||||
fn completeKittyClipboardRead(
|
||||
self: *Surface,
|
||||
req: *const apprt.ClipboardRequest.KittyRead,
|
||||
data: []const u8,
|
||||
) !void {
|
||||
const kitty_clipboard = terminal.kitty.clipboard;
|
||||
|
||||
// Serve the requested representations in request order. The apprt
|
||||
// clipboard read path only carries text today, so the contents are
|
||||
// served under every requested text MIME name; other types are
|
||||
// simply never served, which is how the protocol communicates an
|
||||
// unavailable representation.
|
||||
var contents_buf: [kitty_clipboard.max_read_mimes]terminal.clipboard.Content = undefined;
|
||||
var contents_len: usize = 0;
|
||||
for (req.mimes) |mime| {
|
||||
if (!terminal.clipboard.isTextMime(mime)) continue;
|
||||
contents_buf[contents_len] = .{ .mime = mime, .data = data };
|
||||
contents_len += 1;
|
||||
}
|
||||
|
||||
// Encode the full success sequence: the OK packet, the targets
|
||||
// listing if it was requested (reporting the canonical text type
|
||||
// only when we have contents to serve), DATA chunks for each
|
||||
// served representation, and the final DONE packet.
|
||||
var aw: std.Io.Writer.Allocating = .init(self.alloc);
|
||||
defer aw.deinit();
|
||||
try (kitty_clipboard.ReadSuccess{
|
||||
.primary = req.location == .primary,
|
||||
.id = req.id,
|
||||
.list = req.list,
|
||||
.available = if (data.len > 0) &.{"text/plain"} else &.{},
|
||||
.contents = contents_buf[0..contents_len],
|
||||
.terminator = req.terminator,
|
||||
}).encode(&aw.writer);
|
||||
|
||||
self.queueIo(.{ .write_alloc = .{
|
||||
.alloc = self.alloc,
|
||||
.data = try aw.toOwnedSlice(),
|
||||
} }, .unlocked);
|
||||
}
|
||||
|
||||
fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void {
|
||||
// Wyhash is used to hash the contents of the desktop notification to limit
|
||||
// how fast identical notifications can be sent sequentially.
|
||||
|
||||
@@ -27,6 +27,7 @@ pub const Target = action.Target;
|
||||
pub const ContentScale = structs.ContentScale;
|
||||
pub const Clipboard = structs.Clipboard;
|
||||
pub const ClipboardContent = structs.ClipboardContent;
|
||||
pub const ClipboardReadResult = structs.ClipboardReadResult;
|
||||
pub const ClipboardRequest = structs.ClipboardRequest;
|
||||
pub const ClipboardRequestType = structs.ClipboardRequestType;
|
||||
pub const ColorScheme = structs.ColorScheme;
|
||||
|
||||
@@ -52,11 +52,17 @@ pub const App = struct {
|
||||
/// Callback called to handle an action.
|
||||
action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool,
|
||||
|
||||
/// Read the clipboard value. Returns true if the clipboard request
|
||||
/// was started and complete_clipboard_request may be called with the
|
||||
/// given state pointer. Returns false if the clipboard request couldn't
|
||||
/// be started (such as when no text is available for a paste request).
|
||||
read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool,
|
||||
/// Read the clipboard value. The result only reports facts about
|
||||
/// the clipboard: whether the request was started (in which case
|
||||
/// complete_clipboard_request must eventually be called with the
|
||||
/// given state pointer), whether the clipboard has no servable
|
||||
/// contents, or whether the clipboard can't be read at all. How
|
||||
/// each non-started state is answered is up to the core.
|
||||
read_clipboard: *const fn (
|
||||
SurfaceUD,
|
||||
c_int,
|
||||
*apprt.ClipboardRequest,
|
||||
) callconv(.c) apprt.ClipboardReadResult,
|
||||
|
||||
/// This may be called after a read clipboard call to request
|
||||
/// confirmation that the clipboard value is safe to read. The embedder
|
||||
@@ -692,7 +698,7 @@ pub const Surface = struct {
|
||||
self: *Surface,
|
||||
clipboard_type: apprt.Clipboard,
|
||||
state: apprt.ClipboardRequest,
|
||||
) !bool {
|
||||
) !apprt.ClipboardReadResult {
|
||||
// We need to allocate to get a pointer to store our clipboard request
|
||||
// so that it is stable until the read_clipboard callback and call
|
||||
// complete_clipboard_request. This sucks but clipboard requests aren't
|
||||
@@ -702,27 +708,32 @@ pub const Surface = struct {
|
||||
errdefer alloc.destroy(state_ptr);
|
||||
state_ptr.* = state;
|
||||
|
||||
const started = self.app.opts.read_clipboard(
|
||||
const result = self.app.opts.read_clipboard(
|
||||
self.userdata,
|
||||
@intCast(@intFromEnum(clipboard_type)),
|
||||
state_ptr,
|
||||
);
|
||||
if (!started) {
|
||||
alloc.destroy(state_ptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// Only a started request completes later and keeps the state.
|
||||
if (result != .started) alloc.destroy(state_ptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
fn completeClipboardRequest(
|
||||
self: *Surface,
|
||||
str: [:0]const u8,
|
||||
str_: ?[:0]const u8,
|
||||
state: *apprt.ClipboardRequest,
|
||||
confirmed: bool,
|
||||
) void {
|
||||
const alloc = self.app.core_app.alloc;
|
||||
|
||||
// No string means the request was denied by the user.
|
||||
const str = str_ orelse {
|
||||
self.core_surface.denyClipboardRequest(state.*);
|
||||
alloc.destroy(state);
|
||||
return;
|
||||
};
|
||||
|
||||
// Attempt to complete the request, but we may request
|
||||
// confirmation.
|
||||
self.core_surface.completeClipboardRequest(
|
||||
@@ -1998,14 +2009,18 @@ pub const CAPI = struct {
|
||||
/// Complete a clipboard read request started via the read callback.
|
||||
/// This can only be called once for a given request. Once it is called
|
||||
/// with a request the request pointer will be invalidated.
|
||||
///
|
||||
/// A null string denies the request: request types whose protocol
|
||||
/// expects an answer (e.g. Kitty clipboard protocol reads) have
|
||||
/// their denial reply written to the pty.
|
||||
export fn ghostty_surface_complete_clipboard_request(
|
||||
ptr: *Surface,
|
||||
str: [*:0]const u8,
|
||||
str: ?[*:0]const u8,
|
||||
state: *apprt.ClipboardRequest,
|
||||
confirmed: bool,
|
||||
) void {
|
||||
ptr.completeClipboardRequest(
|
||||
std.mem.sliceTo(str, 0),
|
||||
if (str) |v| std.mem.sliceTo(v, 0) else null,
|
||||
state,
|
||||
confirmed,
|
||||
);
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn clipboardRequest(
|
||||
self: *Self,
|
||||
clipboard_type: apprt.Clipboard,
|
||||
state: apprt.ClipboardRequest,
|
||||
) !bool {
|
||||
) !apprt.ClipboardReadResult {
|
||||
return try self.surface.clipboardRequest(
|
||||
clipboard_type,
|
||||
state,
|
||||
|
||||
@@ -197,7 +197,7 @@ pub const ClipboardConfirmationDialog = extern struct {
|
||||
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."));
|
||||
},
|
||||
.osc_52_read => {
|
||||
.osc_52_read, .kitty_read => {
|
||||
self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
|
||||
self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below."));
|
||||
},
|
||||
|
||||
@@ -1725,7 +1725,7 @@ pub const Surface = extern struct {
|
||||
self: *Self,
|
||||
clipboard_type: apprt.Clipboard,
|
||||
state: apprt.ClipboardRequest,
|
||||
) !bool {
|
||||
) !apprt.ClipboardReadResult {
|
||||
return try Clipboard.request(
|
||||
self,
|
||||
clipboard_type,
|
||||
@@ -4111,22 +4111,23 @@ const Clipboard = struct {
|
||||
);
|
||||
}
|
||||
|
||||
/// Request data from the clipboard (read the clipboard). This
|
||||
/// completes asynchronously and will call the `completeClipboardRequest`
|
||||
/// core surface API when done.
|
||||
///
|
||||
/// Returns true if the request was started, false if the clipboard
|
||||
/// doesn't contain text (allowing performable keybinds to pass through).
|
||||
/// Request data from the clipboard (read the clipboard). A started
|
||||
/// request completes asynchronously and will call the
|
||||
/// `completeClipboardRequest` core surface API when done.
|
||||
pub fn request(
|
||||
self: *Surface,
|
||||
clipboard_type: apprt.Clipboard,
|
||||
state: apprt.ClipboardRequest,
|
||||
) Allocator.Error!bool {
|
||||
) Allocator.Error!apprt.ClipboardReadResult {
|
||||
// The GTK apprt doesn't support Kitty clipboard protocol reads
|
||||
// yet.
|
||||
if (state == .kitty_read) return .unsupported;
|
||||
|
||||
// Get our requested clipboard
|
||||
const clipboard = get(
|
||||
self.private().gl_area.as(gtk.Widget),
|
||||
clipboard_type,
|
||||
) orelse return false;
|
||||
) orelse return .unsupported;
|
||||
|
||||
// For paste requests, check if clipboard has text format available.
|
||||
// This is a synchronous check that allows performable keybinds to
|
||||
@@ -4135,7 +4136,7 @@ const Clipboard = struct {
|
||||
const formats = clipboard.getFormats();
|
||||
if (formats.containGtype(gobject.ext.types.string) == 0) {
|
||||
log.debug("clipboard has no text format, not starting paste request", .{});
|
||||
return false;
|
||||
return .unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4158,7 +4159,7 @@ const Clipboard = struct {
|
||||
ud,
|
||||
);
|
||||
|
||||
return true;
|
||||
return .started;
|
||||
}
|
||||
|
||||
/// Paste explicit text directly into the surface, regardless of the
|
||||
@@ -4224,7 +4225,7 @@ const Clipboard = struct {
|
||||
.request = &req,
|
||||
.@"can-remember" = switch (req) {
|
||||
.osc_52_read, .osc_52_write => true,
|
||||
.paste => false,
|
||||
.paste, .kitty_read => false,
|
||||
},
|
||||
.@"clipboard-contents" = contents_buf,
|
||||
},
|
||||
@@ -4261,7 +4262,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 => {},
|
||||
.paste, .kitty_read => {},
|
||||
};
|
||||
|
||||
// Get our text
|
||||
@@ -4300,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 => @panic("paste should not be able to be remembered"),
|
||||
.paste, .kitty_read => @panic("request should not be able to be remembered"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const std = @import("std");
|
||||
const build_config = @import("../build_config.zig");
|
||||
const terminal = @import("../terminal/main.zig");
|
||||
|
||||
/// ContentScale is the ratio between the current DPI and the platform's
|
||||
/// default DPI. This is used to determine how much certain rendered elements
|
||||
@@ -67,6 +69,26 @@ pub const ClipboardRequestType = enum(u8) {
|
||||
paste,
|
||||
osc_52_read,
|
||||
osc_52_write,
|
||||
kitty_read,
|
||||
};
|
||||
|
||||
/// The result of starting a clipboard read request. This only reports
|
||||
/// facts about the clipboard; how each state is answered on the wire
|
||||
/// (if at all) is up to the protocol handling of the requester.
|
||||
///
|
||||
/// If this is changed, you must also update ghostty.h
|
||||
pub const ClipboardReadResult = enum(c_int) {
|
||||
/// The request was started and will be completed asynchronously
|
||||
/// via the core surface completeClipboardRequest API.
|
||||
started = 0,
|
||||
|
||||
/// The clipboard exists but has no contents the apprt can serve
|
||||
/// (e.g. no text-like data). The request was not started.
|
||||
unavailable = 1,
|
||||
|
||||
/// The clipboard itself can't be read (e.g. a primary selection on
|
||||
/// a platform without one). The request was not started.
|
||||
unsupported = 2,
|
||||
};
|
||||
|
||||
/// Clipboard request. This is used to request clipboard contents and must
|
||||
@@ -81,6 +103,43 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
|
||||
/// A request to write clipboard contents via OSC 52.
|
||||
osc_52_write: Clipboard,
|
||||
|
||||
/// A request to read clipboard contents via the Kitty clipboard
|
||||
/// protocol (OSC 5522).
|
||||
kitty_read: *KittyRead,
|
||||
|
||||
/// State for one in-flight Kitty clipboard protocol read. This is
|
||||
/// created on the IO thread and completed on the app thread, so it
|
||||
/// owns all of its memory: everything, including the struct itself,
|
||||
/// is allocated from the arena.
|
||||
pub const KittyRead = struct {
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
/// The clipboard being read. The protocol can only name the
|
||||
/// standard clipboard or the primary selection.
|
||||
location: Clipboard,
|
||||
|
||||
/// The requested MIME types in request order, already capped at
|
||||
/// terminal.kitty.clipboard.max_read_mimes by the sender. Only
|
||||
/// these representations may be served in the response.
|
||||
mimes: []const []const u8,
|
||||
|
||||
/// True when the targets ('.') listing was requested.
|
||||
list: bool,
|
||||
|
||||
/// The sanitized request id, echoed in every response packet.
|
||||
id: []const u8,
|
||||
|
||||
/// The response terminator, matching the request's.
|
||||
terminator: terminal.osc.Terminator,
|
||||
|
||||
pub fn destroy(self: *KittyRead) 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(
|
||||
|
||||
@@ -72,6 +72,11 @@ pub const Message = union(enum) {
|
||||
/// Read the clipboard and write to the pty.
|
||||
clipboard_read: apprt.Clipboard,
|
||||
|
||||
/// A Kitty clipboard protocol (OSC 5522) read request. The receiver
|
||||
/// takes ownership of the request state and must eventually destroy
|
||||
/// it.
|
||||
kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead,
|
||||
|
||||
/// Write the clipboard contents.
|
||||
clipboard_write: struct {
|
||||
clipboard_type: apprt.Clipboard,
|
||||
|
||||
@@ -352,11 +352,11 @@ pub const StreamHandler = struct {
|
||||
.apc_end => try self.apcEnd(),
|
||||
.apc_put => self.apc.feed(self.alloc, value),
|
||||
.apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
|
||||
.kitty_clipboard => try self.kittyClipboard(value),
|
||||
|
||||
// Unimplemented
|
||||
.title_push,
|
||||
.title_pop,
|
||||
.kitty_clipboard,
|
||||
.kitty_dnd,
|
||||
=> {},
|
||||
}
|
||||
@@ -990,6 +990,126 @@ pub const StreamHandler = struct {
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle one Kitty clipboard protocol (OSC 5522) packet.
|
||||
fn kittyClipboard(
|
||||
self: *StreamHandler,
|
||||
v: terminal.osc.Command.KittyClipboardProtocol,
|
||||
) !void {
|
||||
const kitty_clipboard = terminal.kitty.clipboard;
|
||||
|
||||
// Decode and validate the metadata. Malformed metadata drops
|
||||
// the packet without any response, matching kitty.
|
||||
var arena: std.heap.ArenaAllocator = .init(self.alloc);
|
||||
defer arena.deinit();
|
||||
const meta = (try kitty_clipboard.Metadata.parse(
|
||||
arena.allocator(),
|
||||
v.metadata,
|
||||
)) orelse return;
|
||||
|
||||
switch (meta.op) {
|
||||
.read => try self.kittyClipboardRead(
|
||||
&meta,
|
||||
v.payload orelse "",
|
||||
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(),
|
||||
} });
|
||||
},
|
||||
|
||||
// Data packets without an accepted write transaction are
|
||||
// silently ignored, matching kitty.
|
||||
.wdata, .walias => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn kittyClipboardRead(
|
||||
self: *StreamHandler,
|
||||
meta: *const terminal.kitty.clipboard.Metadata,
|
||||
payload: []const u8,
|
||||
terminator: terminal.osc.Terminator,
|
||||
) !void {
|
||||
const kitty_clipboard = terminal.kitty.clipboard;
|
||||
|
||||
// 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();
|
||||
|
||||
// The payload is the requested MIME list. A read request with
|
||||
// an undecodable payload is dropped without any response,
|
||||
// matching kitty.
|
||||
const decoded = kitty_clipboard.Payload.init(
|
||||
alloc,
|
||||
payload,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Invalid => {
|
||||
arena.deinit();
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
// The targets type ('.') asks for the listing of available
|
||||
// types rather than data. Requested types beyond the cap are
|
||||
// dropped and simply never served, which is how the protocol
|
||||
// reports an unavailable type anyway. The MIME slices point
|
||||
// into the decoded payload, which shares the request arena.
|
||||
var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
|
||||
var mimes_len: usize = 0;
|
||||
var list = false;
|
||||
var it = decoded.mimeIterator();
|
||||
while (it.next()) |mime| {
|
||||
if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
|
||||
list = true;
|
||||
continue;
|
||||
}
|
||||
if (mimes_len == mimes_buf.len) continue;
|
||||
mimes_buf[mimes_len] = mime;
|
||||
mimes_len += 1;
|
||||
}
|
||||
|
||||
// Note: session grants (the pw/name metadata) aren't
|
||||
// implemented in the GUI clipboard path yet, so every request
|
||||
// goes through the configured clipboard-read policy.
|
||||
|
||||
const req = try alloc.create(apprt.ClipboardRequest.KittyRead);
|
||||
const mimes = try alloc.dupe([]const u8, mimes_buf[0..mimes_len]);
|
||||
const id = try alloc.dupe(u8, meta.id);
|
||||
req.* = .{
|
||||
// The arena must be copied in last so it tracks every
|
||||
// allocation above.
|
||||
.arena = arena,
|
||||
.location = switch (meta.loc) {
|
||||
.primary => .primary,
|
||||
else => .standard,
|
||||
},
|
||||
.mimes = mimes,
|
||||
.list = list,
|
||||
.id = id,
|
||||
.terminator = terminator,
|
||||
};
|
||||
|
||||
self.surfaceMessageWriter(.{ .kitty_clipboard_read = req });
|
||||
}
|
||||
|
||||
fn semanticPrompt(
|
||||
self: *StreamHandler,
|
||||
cmd: Stream.Action.SemanticPrompt,
|
||||
|
||||
Reference in New Issue
Block a user