mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 17:11:40 +00:00
macOS: enable mode 5522 paste events
Advertise Kitty clipboard protocol mode 5522 on macOS and route clipboard paste requests through the protocol when it is enabled.
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_LIST,
|
||||
} ghostty_clipboard_request_e;
|
||||
|
||||
// apprt.ClipboardReadResult
|
||||
|
||||
@@ -91,14 +91,25 @@ extension NSPasteboard {
|
||||
|
||||
/// The MIME types available on the pasteboard, best-effort mapped
|
||||
/// from the pasteboard types. Types without a MIME mapping are not
|
||||
/// reported.
|
||||
/// reported. This only inspects declared types and never reads data,
|
||||
/// since mode 5522 paste events must remain metadata-only.
|
||||
func ghosttyAvailableMimes() -> [String] {
|
||||
var result: [String] = []
|
||||
var seen = Set<String>()
|
||||
let availableTypes = types ?? []
|
||||
let mimeType: (NSPasteboard.PasteboardType) -> String? = { type in
|
||||
guard let mime = UTType(type.rawValue)?.preferredMIMEType else { return nil }
|
||||
return mime == "text/plain;charset=utf-8" ? "text/plain" : mime
|
||||
}
|
||||
|
||||
// Any text-like contents are reported under the canonical type,
|
||||
// matching what ghosttyData(forMime:) serves.
|
||||
if getOpinionatedStringContents() != nil {
|
||||
// Plain text and copied files can both be served as the canonical
|
||||
// text representation. Infer this from declared types so lazy
|
||||
// pasteboard providers are not asked for their contents.
|
||||
let hasFileURL = availableTypes.contains(.fileURL)
|
||||
let hasPlainText = hasFileURL || availableTypes.contains { type in
|
||||
mimeType(type) == "text/plain"
|
||||
}
|
||||
if hasPlainText {
|
||||
result.append("text/plain")
|
||||
seen.insert("text/plain")
|
||||
}
|
||||
@@ -106,14 +117,13 @@ extension NSPasteboard {
|
||||
// Copied files are additionally served as a URI list. The
|
||||
// generic mapping below never reports this since file URL
|
||||
// pasteboard types have no MIME type.
|
||||
if !ghosttyFileURLs.isEmpty {
|
||||
if hasFileURL {
|
||||
result.append("text/uri-list")
|
||||
seen.insert("text/uri-list")
|
||||
}
|
||||
|
||||
for type in types ?? [] {
|
||||
guard let utType = UTType(type.rawValue),
|
||||
let mime = utType.preferredMIMEType,
|
||||
for type in availableTypes {
|
||||
guard let mime = mimeType(type),
|
||||
!seen.contains(mime) else { continue }
|
||||
seen.insert(mime)
|
||||
result.append(mime)
|
||||
|
||||
@@ -4077,7 +4077,7 @@ pub fn mouseButtonCallback(
|
||||
else
|
||||
.standard,
|
||||
};
|
||||
_ = try self.startClipboardRequest(clipboard, .{ .paste = {} });
|
||||
_ = try self.startClipboardRequest(clipboard, .{ .paste = clipboard });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4161,7 +4161,7 @@ pub fn mouseButtonCallback(
|
||||
// request so we need to unlock.
|
||||
self.renderer_state.mutex.unlock(global.io());
|
||||
defer self.renderer_state.mutex.lockUncancelable(global.io());
|
||||
_ = try self.startClipboardRequest(.standard, .paste);
|
||||
_ = try self.startClipboardRequest(.standard, .{ .paste = .standard });
|
||||
|
||||
// We don't need to clear selection because we didn't have
|
||||
// one to begin with.
|
||||
@@ -4176,7 +4176,7 @@ pub fn mouseButtonCallback(
|
||||
// request so we need to unlock.
|
||||
self.renderer_state.mutex.unlock(global.io());
|
||||
defer self.renderer_state.mutex.lockUncancelable(global.io());
|
||||
_ = try self.startClipboardRequest(.standard, .paste);
|
||||
_ = try self.startClipboardRequest(.standard, .{ .paste = .standard });
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5111,12 +5111,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
|
||||
.paste_from_clipboard => return (try self.startClipboardRequest(
|
||||
.standard,
|
||||
.{ .paste = {} },
|
||||
.{ .paste = .standard },
|
||||
)) == .started,
|
||||
|
||||
.paste_from_selection => return (try self.startClipboardRequest(
|
||||
.selection,
|
||||
.{ .paste = {} },
|
||||
.{ .paste = .selection },
|
||||
)) == .started,
|
||||
|
||||
.increase_font_size => |delta| {
|
||||
@@ -5897,6 +5897,13 @@ pub fn completeClipboardRequest(
|
||||
complete.confirmed,
|
||||
),
|
||||
|
||||
.list => |clipboard| if (!try self.completeClipboardPasteEvent(
|
||||
clipboard,
|
||||
complete.available,
|
||||
)) {
|
||||
log.debug("mode 5522 paste event was not written", .{});
|
||||
},
|
||||
|
||||
.osc_52_read => |clipboard| try self.completeClipboardReadOSC52(
|
||||
clipboardTextContent(complete.contents) orelse "",
|
||||
clipboard,
|
||||
@@ -5969,7 +5976,7 @@ fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u
|
||||
pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void {
|
||||
switch (req) {
|
||||
// A denied paste simply doesn't happen.
|
||||
.paste => {},
|
||||
.paste, .list => {},
|
||||
|
||||
// OSC 52 has no error responses, but the client is waiting on
|
||||
// a reply, so a denied read is answered with empty contents.
|
||||
@@ -6006,8 +6013,25 @@ fn startClipboardRequest(
|
||||
loc: apprt.Clipboard,
|
||||
req: apprt.ClipboardRequest,
|
||||
) !apprt.ClipboardReadResult {
|
||||
switch (req) {
|
||||
.paste => {}, // always allowed
|
||||
const effective_req: apprt.ClipboardRequest = switch (req) {
|
||||
.paste => |clipboard| effective: {
|
||||
// Snapshot the mode before asking the apprt for clipboard data.
|
||||
// Event pastes request only a MIME listing, while ordinary
|
||||
// pastes request the text representation as before.
|
||||
self.renderer_state.mutex.lockUncancelable(global.io());
|
||||
const event = self.io.terminal.modes.get(.kitty_paste_events);
|
||||
self.renderer_state.mutex.unlock(global.io());
|
||||
|
||||
break :effective if (event)
|
||||
.{ .list = clipboard }
|
||||
else
|
||||
req;
|
||||
},
|
||||
else => req,
|
||||
};
|
||||
|
||||
switch (effective_req) {
|
||||
.paste, .list => {}, // always allowed
|
||||
.osc_52_read => if (self.config.clipboard_read == .deny) {
|
||||
log.info(
|
||||
"application attempted to read clipboard, but 'clipboard-read' is set to deny",
|
||||
@@ -6024,7 +6048,7 @@ fn startClipboardRequest(
|
||||
.osc_52_write => unreachable,
|
||||
}
|
||||
|
||||
return try self.rt_surface.clipboardRequest(loc, req);
|
||||
return try self.rt_surface.clipboardRequest(loc, effective_req);
|
||||
}
|
||||
|
||||
fn completeClipboardPaste(
|
||||
@@ -6106,6 +6130,63 @@ fn completeClipboardPaste(
|
||||
};
|
||||
}
|
||||
|
||||
/// Send a Kitty clipboard-protocol paste event when mode 5522 is enabled.
|
||||
/// The event only lists the available MIME types; it does not read any of
|
||||
/// their data. The shared terminal paste implementation generates and records
|
||||
/// the one-time password used by the program's follow-up OSC 5522 read.
|
||||
fn completeClipboardPasteEvent(
|
||||
self: *Surface,
|
||||
clipboard: apprt.Clipboard,
|
||||
available: []const []const u8,
|
||||
) !bool {
|
||||
if (self.readonly) return false;
|
||||
|
||||
const kitty_clipboard = terminal.kitty.clipboard;
|
||||
const location: terminal.clipboard.Location = switch (clipboard) {
|
||||
.standard => .standard,
|
||||
.selection => .selection,
|
||||
.primary => .primary,
|
||||
};
|
||||
|
||||
// The protocol implementation caps listings at this size too. Cap here
|
||||
// so the temporary Content array stays on the stack.
|
||||
var contents_buf: [kitty_clipboard.max_listing_mimes]terminal.clipboard.Content = undefined;
|
||||
const contents_len = @min(available.len, contents_buf.len);
|
||||
for (available[0..contents_len], contents_buf[0..contents_len]) |mime, *content| {
|
||||
content.* = .{ .mime = mime, .data = "" };
|
||||
}
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(self.alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
self.renderer_state.mutex.lockUncancelable(global.io());
|
||||
defer self.renderer_state.mutex.unlock(global.io());
|
||||
|
||||
const pasted = try terminal.paste.paste(.{
|
||||
.terminal = &self.io.terminal,
|
||||
.alloc = self.alloc,
|
||||
.writer = &aw.writer,
|
||||
.kitty_clipboard = .{
|
||||
.grants = &self.io.terminal_stream.handler.kitty_clipboard_grants,
|
||||
.io = global.io(),
|
||||
},
|
||||
}, .{
|
||||
.source = .{ .clipboard = location },
|
||||
.contents = .{ .memory = contents_buf[0..contents_len] },
|
||||
// A paste event discloses no clipboard data, so unsafe-text
|
||||
// confirmation does not apply. If mode 5522 is reset, the empty
|
||||
// stand-in representations cause the shared helper to write nothing.
|
||||
.allow_unsafe = true,
|
||||
});
|
||||
if (!pasted) return false;
|
||||
|
||||
self.queueIo(.{ .write_alloc = .{
|
||||
.alloc = self.alloc,
|
||||
.data = try aw.toOwnedSlice(),
|
||||
} }, .locked);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn completeClipboardReadOSC52(
|
||||
self: *Surface,
|
||||
data: []const u8,
|
||||
|
||||
@@ -720,6 +720,10 @@ pub const Surface = struct {
|
||||
const mimes: []const [*:0]const u8 = switch (state) {
|
||||
.paste, .osc_52_read => &.{"text/plain"},
|
||||
|
||||
// A mode 5522 paste event only lists types and must not read
|
||||
// any clipboard data.
|
||||
.list => &.{},
|
||||
|
||||
.kitty_read => |kitty| mimes: {
|
||||
assert(kitty.mimes.len <= mimes_buf.len);
|
||||
for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| {
|
||||
@@ -735,6 +739,9 @@ pub const Surface = struct {
|
||||
.osc_52_write => unreachable,
|
||||
};
|
||||
const list = switch (state) {
|
||||
// Paste events need the full MIME listing without reading any
|
||||
// representation. Kitty reads only ask for it when requested.
|
||||
.list => true,
|
||||
.kitty_read => |kitty| kitty.list,
|
||||
else => false,
|
||||
};
|
||||
|
||||
@@ -205,6 +205,7 @@ pub const ClipboardConfirmationDialog = extern struct {
|
||||
self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste"));
|
||||
self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."));
|
||||
},
|
||||
.list => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4121,7 +4121,7 @@ const Clipboard = struct {
|
||||
) Allocator.Error!apprt.ClipboardReadResult {
|
||||
// The GTK apprt doesn't support Kitty clipboard protocol reads
|
||||
// yet.
|
||||
if (state == .kitty_read) return .unsupported;
|
||||
if (state == .kitty_read or state == .list) return .unsupported;
|
||||
|
||||
// Get our requested clipboard
|
||||
const clipboard = get(
|
||||
@@ -4172,7 +4172,7 @@ const Clipboard = struct {
|
||||
|
||||
const surface = self.private().core_surface orelse return;
|
||||
surface.completeClipboardRequest(
|
||||
.paste,
|
||||
.{ .paste = .standard },
|
||||
.{ .contents = &.{.{ .mime = "text/plain", .data = text }} },
|
||||
) catch |err| switch (err) {
|
||||
error.UnsafePaste,
|
||||
@@ -4180,7 +4180,7 @@ const Clipboard = struct {
|
||||
=> {
|
||||
showClipboardConfirmation(
|
||||
self,
|
||||
.paste,
|
||||
.{ .paste = .standard },
|
||||
text,
|
||||
);
|
||||
return;
|
||||
@@ -4224,7 +4224,7 @@ const Clipboard = struct {
|
||||
.request = &req,
|
||||
.@"can-remember" = switch (req) {
|
||||
.osc_52_read, .osc_52_write => true,
|
||||
.paste, .kitty_read => false,
|
||||
.paste, .list, .kitty_read => false,
|
||||
},
|
||||
.@"clipboard-contents" = contents_buf,
|
||||
},
|
||||
@@ -4261,7 +4261,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, .kitty_read => {},
|
||||
.paste, .list, .kitty_read => {},
|
||||
};
|
||||
|
||||
// Get our text
|
||||
@@ -4299,7 +4299,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, .kitty_read => @panic("request should not be able to be remembered"),
|
||||
.paste, .list, .kitty_read => @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,
|
||||
list,
|
||||
};
|
||||
|
||||
/// The result of starting a clipboard read request. This only reports
|
||||
@@ -95,7 +96,7 @@ pub const ClipboardReadResult = enum(c_int) {
|
||||
/// be sent as a response to a ClipboardRequest event.
|
||||
pub const ClipboardRequest = union(ClipboardRequestType) {
|
||||
/// A direct paste of clipboard contents.
|
||||
paste: void,
|
||||
paste: Clipboard,
|
||||
|
||||
/// A request to read clipboard contents via OSC 52.
|
||||
osc_52_read: Clipboard,
|
||||
@@ -107,6 +108,10 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
|
||||
/// protocol (OSC 5522).
|
||||
kitty_read: *KittyRead,
|
||||
|
||||
/// A request to list the available clipboard MIME types without
|
||||
/// reading any of their data.
|
||||
list: Clipboard,
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! to ensure all our various types and logic remain in sync.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const build_options = @import("terminal_options");
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -335,8 +336,9 @@ const entries: []const ModeEntry = &.{
|
||||
.{
|
||||
.name = "kitty_paste_events",
|
||||
.value = 5522,
|
||||
// Only libghostty-vt supports this currently
|
||||
.disabled = build_options.artifact != .lib,
|
||||
// The macOS app and libghostty-vt can both serve the follow-up
|
||||
// Kitty clipboard read that a paste event grants.
|
||||
.disabled = build_options.artifact != .lib and builtin.os.tag != .macos,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user