From c2c0db68aa3b33f996a0426f00cd5972dc691ef3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Aug 2026 09:37:22 -0700 Subject: [PATCH] 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. --- include/ghostty.h | 1 + .../Extensions/NSPasteboard+Extension.swift | 26 +++-- src/Surface.zig | 99 +++++++++++++++++-- src/apprt/embedded.zig | 7 ++ .../class/clipboard_confirmation_dialog.zig | 1 + src/apprt/gtk/class/surface.zig | 12 +-- src/apprt/structs.zig | 7 +- src/terminal/modes.zig | 6 +- 8 files changed, 133 insertions(+), 26 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index e467fa0ea..fb0e69ce0 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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 diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index fc13c807a..5952cdb55 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -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() + 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) diff --git a/src/Surface.zig b/src/Surface.zig index 626d514d5..8c42b5445 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 16c3d2cc1..69db394b7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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, }; diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index 2cfe6fa09..61b0b9faa 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -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, } } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 709aedfa3..b81e8b8bb 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -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"), }; } diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 825c85939..37dba17c5 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -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, diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index b1330db66..ed5f1b72a 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -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, }, };