From 0ce9054bf9ff5c4107bbe8a460076012861f9a3a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 23 Aug 2026 21:15:28 -0700 Subject: [PATCH 1/6] macos: implement Kitty clipboard protocol reads (OSC 5522) --- include/ghostty.h | 15 +- .../ClipboardConfirmationController.swift | 2 +- .../ClipboardConfirmationView.swift | 4 +- macos/Sources/Ghostty/Ghostty.App.swift | 29 ++- ...Ghostty.ClipboardConfirmationRequest.swift | 8 +- src/Surface.zig | 178 +++++++++++++++++- src/apprt.zig | 1 + src/apprt/embedded.zig | 45 +++-- src/apprt/gtk/Surface.zig | 2 +- .../class/clipboard_confirmation_dialog.zig | 2 +- src/apprt/gtk/class/surface.zig | 29 +-- src/apprt/structs.zig | 59 ++++++ src/apprt/surface.zig | 5 + src/termio/stream_handler.zig | 122 +++++++++++- 14 files changed, 444 insertions(+), 57 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 5523654c4..f7d565a87 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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*, diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 7c7b9bd3c..897ada880 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -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" } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index c2fe5fd48..e4e78b79e 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -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" } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 943120413..b3e815849 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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) } diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index 3049366f0..87787a637 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -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 } diff --git a/src/Surface.zig b/src/Surface.zig index c57eeab54..8dc200fce 100644 --- a/src/Surface.zig +++ b/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. diff --git a/src/apprt.zig b/src/apprt.zig index c467f1801..ef788c9bb 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -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; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a380dc50c..1ce53b12e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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, ); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c5ba39277..4a59ab21d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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, diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d44d38a35..2cfe6fa09 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -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.")); }, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index ff0d9a7d2..d5302edca 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -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"), }; } diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 2c37dbd5e..693059473 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -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( diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 4a0b8b377..d2ca9171a 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -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, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 1dc5268f7..04edd056e 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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, From 8c7a34d4c9c6a1afcb7a96dcc9aa4665e05d883e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 23 Aug 2026 21:35:02 -0700 Subject: [PATCH 2/6] macos: Kitty clipboard reads serve all clipboard content types --- include/ghostty.h | 27 ++- macos/Sources/Ghostty/Ghostty.App.swift | 182 +++++++++++++++--- ...Ghostty.ClipboardConfirmationRequest.swift | 26 ++- macos/Sources/Ghostty/GhosttyPackage.swift | 15 +- .../Extensions/NSPasteboard+Extension.swift | 41 ++++ src/Surface.zig | 82 +++++--- src/apprt/embedded.zig | 160 ++++++++++++--- src/apprt/gtk/class/surface.zig | 9 +- src/apprt/structs.zig | 6 +- src/termio/stream_handler.zig | 8 +- 10 files changed, 454 insertions(+), 102 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index f7d565a87..3959f46a6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -77,9 +77,12 @@ typedef enum { GHOSTTY_CLIPBOARD_SELECTION, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; typedef enum { @@ -1034,10 +1037,16 @@ typedef void (*ghostty_runtime_wakeup_cb)(void*); typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( void*, ghostty_clipboard_e, - void*); + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_content_s*, + size_t, + const char* const*, + size_t, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1187,10 +1196,16 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_content_s*, + size_t, + const char* const*, + size_t, + void*, + bool); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index b3e815849..72fe703ab 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -60,8 +60,23 @@ extension Ghostty { supports_selection_clipboard: true, wakeup_cb: { userdata in App.wakeup(userdata) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) }, - read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, - confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, + read_clipboard_cb: { userdata, loc, state, mimes, mimesLen, list in + App.readClipboard( + userdata, + location: loc, + state: state, + mimes: mimes, + mimesLen: mimesLen, + list: list) }, + confirm_read_clipboard_cb: { userdata, contents, contentsLen, available, availableLen, state, request in + App.confirmReadClipboard( + userdata, + contents: contents, + contentsLen: contentsLen, + available: available, + availableLen: availableLen, + state: state, + request: request) }, write_clipboard_cb: { userdata, loc, content, len, confirm in App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) }, close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } @@ -286,7 +301,10 @@ extension Ghostty { static func readClipboard( _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, - state: UnsafeMutableRawPointer? + state: UnsafeMutableRawPointer?, + mimes: UnsafePointer?>?, + mimesLen: Int, + list: Bool ) -> ghostty_clipboard_read_result_e { let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { @@ -298,59 +316,160 @@ extension Ghostty { return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED } - // We can only serve text-like clipboard contents. - guard let str = pasteboard.getOpinionatedStringContents() else { + // Gather the representation for each requested MIME type that + // the pasteboard can serve. We only ever read the requested + // representations so unrelated (potentially large) clipboard + // contents are never loaded. + var contents: [Ghostty.ClipboardContent] = [] + var seen = Set() + if let mimes { + for i in 0..?, + contents: UnsafePointer?, + contentsLen: Int, + available: UnsafePointer?>?, + availableLen: Int, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { let surfaceView = self.surfaceUserdata(from: userdata) - guard surfaceView.surface != nil, - let string, - let valueStr = String(cString: string, encoding: .utf8), - let kind = Ghostty.ClipboardRequest.from(request: request) else { return } + guard let surface = surfaceView.surface else { return } + guard let kind = Ghostty.ClipboardRequest.from(request: request) else { + ghostty_surface_deny_clipboard_request(surface, state) + return + } + + // Copy the borrowed C representations: the confirmation is + // asynchronous and completes with exactly what the user + // approved, so the clipboard is never re-read. + var reps: [Ghostty.ClipboardContent] = [] + if let contents { + for i in 0.. 0 { + Data(bytes: c.data, count: c.len) + } else { + Data() + } + reps.append(.init(mime: String(cString: c.mime), data: data)) + } + } + var avail: [String] = [] + if let available { + for i in 0..] = [] + var cDatas: [UnsafeMutableRawPointer] = [] + defer { + cStrings.forEach { free($0) } + cDatas.forEach { $0.deallocate() } } - data.withCString { ptr in - ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed) + var cContents: [ghostty_clipboard_content_s] = [] + for entry in contents { + guard let mime = strdup(entry.mime) else { continue } + cStrings.append(mime) + let buf = UnsafeMutableRawPointer.allocate( + byteCount: max(entry.data.count, 1), + alignment: 1) + cDatas.append(buf) + entry.data.withUnsafeBytes { src in + if let base = src.baseAddress { + buf.copyMemory(from: base, byteCount: src.count) + } + } + cContents.append(ghostty_clipboard_content_s( + mime: mime, + data: buf.assumingMemoryBound(to: CChar.self), + len: entry.data.count)) + } + + var cAvailable: [UnsafePointer?] = [] + for mime in available { + guard let str = strdup(mime) else { continue } + cStrings.append(str) + cAvailable.append(UnsafePointer(str)) + } + + cContents.withUnsafeBufferPointer { contentsBuf in + cAvailable.withUnsafeBufferPointer { availableBuf in + ghostty_surface_complete_clipboard_request( + surface, + contentsBuf.baseAddress, + contentsBuf.count, + availableBuf.baseAddress, + availableBuf.count, + state, + confirmed) + } } } @@ -387,24 +506,25 @@ extension Ghostty { // Set data for each type for item in contentArray { guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue } - pasteboard.setString(item.data, forType: type) + pasteboard.setData(item.data, forType: type) } return } // For confirmation, use the text/plain content if it exists - guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else { + guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }), + let textPlainString = textPlainContent.string else { return } let request = Ghostty.ClipboardConfirmationRequest( surface: surfaceView, - contents: textPlainContent.data, + contents: textPlainString, kind: .osc_52_write - ) { _, contents in - guard let contents else { return } + ) { _, confirmed in + guard confirmed else { return } pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString(contents, forType: .string) + pasteboard.setString(textPlainString, forType: .string) } surfaceView.pendingClipboardConfirmation = request } diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index 87787a637..89fa61fd3 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -62,16 +62,22 @@ extension Ghostty { /// occurs from inside the libghostty callback that created the request. final class ClipboardConfirmationRequest { private(set) weak var surface: SurfaceView? + + /// The textual preview of the clipboard contents shown in the + /// confirmation dialog. The actual representations served on + /// confirmation are held by the completion. let contents: String + let kind: ClipboardRequest - private var completion: ((SurfaceView, String?) -> Void)? + /// Called exactly once with whether the user confirmed the request. + private var completion: ((SurfaceView, Bool) -> Void)? init( surface: SurfaceView, contents: String, kind: ClipboardRequest, - completion: @escaping (SurfaceView, String?) -> Void + completion: @escaping (SurfaceView, Bool) -> Void ) { self.surface = surface self.contents = contents @@ -83,29 +89,29 @@ extension Ghostty { guard let surface, let completion else { return } self.completion = nil DispatchQueue.main.async { - completion(surface, nil) + completion(surface, false) } } - /// Complete the request using the displayed clipboard contents. + /// Complete the request with the displayed clipboard contents. func complete() { - finish(contents) + finish(true) } - /// Cancel the request without using the displayed clipboard contents. + /// Cancel the request, denying access to the clipboard contents. func cancel() { - finish(nil) + finish(false) } /// Cancel using the owning surface explicitly. SurfaceView uses this /// for replacement and teardown because its weak reference is already /// nil during the owner's deinitialization. func cancel(from surface: SurfaceView) { - finish(nil, on: surface) + finish(false, on: surface) } private func finish( - _ contents: String?, + _ confirmed: Bool, on explicitSurface: SurfaceView? = nil ) { guard let surface = explicitSurface ?? self.surface, @@ -114,7 +120,7 @@ extension Ghostty { return } self.completion = nil - completion(surface, contents) + completion(surface, confirmed) } } } diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 313223aa3..0cbdf03f4 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -237,9 +237,14 @@ extension Ghostty.SplitFocusDirection { } extension Ghostty { + /// One representation of clipboard contents. The data is binary-safe; + /// textual consumers use `string`. struct ClipboardContent { let mime: String - let data: String + let data: Data + + /// The data as text, if it is valid UTF-8. + var string: String? { String(data: data, encoding: .utf8) } static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? { guard let mimePtr = content.mime, @@ -247,9 +252,15 @@ extension Ghostty { return nil } + let data: Data = if content.len > 0 { + Data(bytes: dataPtr, count: content.len) + } else { + Data() + } + return ClipboardContent( mime: String(cString: mimePtr), - data: String(cString: dataPtr) + data: data ) } } diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index 9dbed4614..fb152fa16 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -54,6 +54,47 @@ extension NSPasteboard { return strings.joined(separator: " ") } + /// The data for the given MIME type, if the pasteboard can serve it. + /// + /// The canonical "text/plain" type uses the opinionated string + /// contents so that e.g. copying a file yields its escaped path; + /// this matches what pasting into the terminal produces. All other + /// types are mapped through UTType. + func ghosttyData(forMime mime: String) -> Data? { + if mime == "text/plain" { + guard let str = getOpinionatedStringContents() else { return nil } + return Data(str.utf8) + } + + guard let type = NSPasteboard.PasteboardType(mimeType: mime) else { return nil } + return data(forType: type) + } + + /// The MIME types available on the pasteboard, best-effort mapped + /// from the pasteboard types. Types without a MIME mapping are not + /// reported. + func ghosttyAvailableMimes() -> [String] { + var result: [String] = [] + var seen = Set() + + // Any text-like contents are reported under the canonical type, + // matching what ghosttyData(forMime:) serves. + if getOpinionatedStringContents() != nil { + result.append("text/plain") + seen.insert("text/plain") + } + + for type in types ?? [] { + guard let utType = UTType(type.rawValue), + let mime = utType.preferredMIMEType, + !seen.contains(mime) else { continue } + seen.insert(mime) + result.append(mime) + } + + return result + } + /// The pasteboard for the Ghostty enum type. static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { switch clipboard { diff --git a/src/Surface.zig b/src/Surface.zig index 8dc200fce..4a2b8a335 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5848,8 +5848,14 @@ fn writeScreenFile( } /// Call this to complete a clipboard request sent to apprt. This should -/// only be called once for each request. The data is immediately copied so -/// it is safe to free the data after this call. +/// only be called once for each request. All contents are immediately +/// copied so it is safe to free them after this call. +/// +/// The contents are the representations the apprt could serve for the +/// request's MIME types and `available` is the listing of MIME types on +/// the clipboard (only gathered when the request asked for it). +/// Requesters that only carry text (paste, OSC 52) use the first +/// text-like representation. /// /// If `confirmed` is true then any clipboard confirmation prompts are skipped: /// @@ -5857,30 +5863,41 @@ fn writeScreenFile( /// data is defined as data that contains newlines, though this definition /// may change later to detect other scenarios. /// -/// - For OSC 52 reads and writes no prompt is shown to the user if -/// `confirmed` is true. +/// - For OSC 52 and Kitty clipboard protocol reads and writes no prompt +/// is shown to the user if `confirmed` is true. /// /// If `confirmed` is false then this may return either an UnsafePaste or /// UnauthorizedPaste error, depending on the type of clipboard request. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, - data: [:0]const u8, + contents: []const terminal.clipboard.Content, + available: []const []const u8, confirmed: bool, ) !void { switch (req) { - .paste => try self.completeClipboardPaste(data, confirmed), + .paste => try self.completeClipboardPaste( + clipboardTextContent(contents) orelse "", + confirmed, + ), .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - data, + clipboardTextContent(contents) orelse "", clipboard, confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ - .mime = "text/plain", - .data = data, - }}, !confirmed), + .osc_52_write => |clipboard| { + // The write API wants sentinel-terminated data; the write + // text round-tripped through the apprt confirmation flow as + // a plain representation. + const data = try self.alloc.dupeZ(u8, clipboardTextContent(contents) orelse ""); + defer self.alloc.free(data); + try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !confirmed); + }, .kitty_read => |kitty| { // If we need confirmation we return an error without @@ -5891,11 +5908,19 @@ pub fn completeClipboardRequest( } defer kitty.destroy(); - try self.completeKittyClipboardRead(kitty, data); + try self.completeKittyClipboardRead(kitty, contents, available); }, } } +/// The first text-like representation of the contents, if any. +fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u8 { + for (contents) |content| { + if (terminal.clipboard.isTextMime(content.mime)) return content.data; + } + return null; +} + /// 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. @@ -6118,7 +6143,7 @@ fn kittyClipboardRead( // to disclose. .unavailable => { defer req.destroy(); - try self.completeKittyClipboardRead(req, ""); + try self.completeKittyClipboardRead(req, &.{}, &.{}); }, // The apprt can't serve this clipboard at all, e.g. an @@ -6156,34 +6181,47 @@ fn kittyClipboardReadStatus( fn completeKittyClipboardRead( self: *Surface, req: *const apprt.ClipboardRequest.KittyRead, - data: []const u8, + contents: []const terminal.clipboard.Content, + available: []const []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 + // Serve the requested representations in request order under their + // requested names. Text-like MIME aliases all match the canonical + // text representation, since that is the only name the apprt + // serves text under. Requested types without a representation 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; + const data: []const u8 = data: { + for (contents) |content| { + if (std.mem.eql(u8, content.mime, mime)) break :data content.data; + if (terminal.clipboard.isTextMime(mime) and + terminal.clipboard.isTextMime(content.mime)) + { + break :data content.data; + } + } + + 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. + // listing if it was requested, 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 &.{}, + .available = available, .contents = contents_buf[0..contents_len], .terminator = req.terminator, }).encode(&aw.writer); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1ce53b12e..88713174a 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -54,22 +54,37 @@ pub const App = struct { /// 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. + /// complete_clipboard_request or deny_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. + /// + /// The MIME types are exactly the representations the caller wants + /// served; the embedder should read only those. Text-like types + /// are always requested as the canonical "text/plain". The final + /// bool asks for the listing of all MIME types available on the + /// clipboard to be delivered with the completion. read_clipboard: *const fn ( SurfaceUD, c_int, *apprt.ClipboardRequest, + [*]const [*:0]const u8, + usize, + bool, ) 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 - /// must call complete_clipboard_request with the given request. + /// confirmation that the clipboard value is safe to read. The + /// embedder must call complete_clipboard_request (usually with + /// these same contents, which are only borrowed for this call) or + /// deny_clipboard_request with the given request. confirm_read_clipboard: *const fn ( SurfaceUD, - [*:0]const u8, + ?[*]const CAPI.ClipboardContent, + usize, + ?[*]const [*:0]const u8, + usize, *apprt.ClipboardRequest, apprt.ClipboardRequestType, ) callconv(.c) void, @@ -699,6 +714,34 @@ pub const Surface = struct { clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, ) !apprt.ClipboardReadResult { + // 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 + // text-like aliases normalized so the embedder never has to + // know about them. + var mimes_buf: [terminal.kitty.clipboard.max_read_mimes][*:0]const u8 = undefined; + const mimes: []const [*:0]const u8 = switch (state) { + .paste, .osc_52_read => &.{"text/plain"}, + + .kitty_read => |kitty| mimes: { + assert(kitty.mimes.len <= mimes_buf.len); + for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| { + dst.* = if (terminal.clipboard.isTextMime(mime)) + "text/plain" + else + mime.ptr; + } + break :mimes mimes_buf[0..kitty.mimes.len]; + }, + + // No clipboard write code paths travel through this function + .osc_52_write => unreachable, + }; + const list = switch (state) { + .kitty_read => |kitty| kitty.list, + else => false, + }; + // 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 @@ -712,6 +755,9 @@ pub const Surface = struct { self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, + mimes.ptr, + mimes.len, + list, ); // Only a started request completes later and keeps the state. @@ -721,24 +767,57 @@ pub const Surface = struct { fn completeClipboardRequest( self: *Surface, - str_: ?[:0]const u8, + contents_: ?[*]const CAPI.ClipboardContent, + contents_len: usize, + available_: ?[*]const [*:0]const u8, + available_len: usize, 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.*); + // Convert the C representations to the core types. Everything + // remains borrowed from the caller for the duration of the call. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + + const raw_contents: []const CAPI.ClipboardContent = + if (contents_) |v| v[0..contents_len] else &.{}; + const contents = conv_alloc.alloc( + terminal.clipboard.Content, + raw_contents.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); alloc.destroy(state); return; }; + defer conv_alloc.free(contents); + for (raw_contents, contents) |raw, *content| content.* = .{ + .mime = std.mem.sliceTo(raw.mime, 0), + .data = raw.data[0..raw.len], + }; + + const raw_available: []const [*:0]const u8 = + if (available_) |v| v[0..available_len] else &.{}; + const available = conv_alloc.alloc( + []const u8, + raw_available.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(available); + for (raw_available, available) |raw, *mime| { + mime.* = std.mem.sliceTo(raw, 0); + } // Attempt to complete the request, but we may request // confirmation. self.core_surface.completeClipboardRequest( state.*, - str, + contents, + available, confirmed, ) catch |err| switch (err) { error.UnsafePaste, @@ -746,7 +825,10 @@ pub const Surface = struct { => { self.app.opts.confirm_read_clipboard( self.userdata, - str.ptr, + contents_, + contents_len, + available_, + available_len, state, state.*, ); @@ -762,6 +844,14 @@ pub const Surface = struct { alloc.destroy(state); } + fn denyClipboardRequest( + self: *Surface, + state: *apprt.ClipboardRequest, + ) void { + self.core_surface.denyClipboardRequest(state.*); + self.app.core_app.alloc.destroy(state); + } + pub fn setClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, @@ -774,7 +864,8 @@ pub const Surface = struct { for (contents, 0..) |content, i| { array[i] = .{ .mime = content.mime, - .data = content.data, + .data = content.data.ptr, + .len = content.data.len, }; } @@ -1312,9 +1403,13 @@ pub const CAPI = struct { }; // ghostty_clipboard_content_s + // + // One representation of clipboard contents. The data is binary-safe + // and its length is explicit; it is not sentinel-terminated. const ClipboardContent = extern struct { mime: [*:0]const u8, - data: [*:0]const u8, + data: [*]const u8, + len: usize, }; // ghostty_text_s @@ -2006,26 +2101,45 @@ 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. + /// Complete a clipboard read request started via the read callback + /// with the representations that could be served and, if requested, + /// the listing of available MIME types. All memory is borrowed for + /// the duration of the call. 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. + /// To deny a request use ghostty_surface_deny_clipboard_request + /// instead. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - str: ?[*:0]const u8, + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, state: *apprt.ClipboardRequest, confirmed: bool, ) void { ptr.completeClipboardRequest( - if (str) |v| std.mem.sliceTo(v, 0) else null, + contents, + contents_len, + available, + available_len, state, confirmed, ); } + /// Deny a clipboard read request started via the read callback, + /// e.g. because the user rejected a confirmation prompt. Request + /// types whose protocol expects an answer have their denial reply + /// written to the pty. The request pointer is invalidated. + export fn ghostty_surface_deny_clipboard_request( + ptr: *Surface, + state: *apprt.ClipboardRequest, + ) void { + ptr.denyClipboardRequest(state); + } + export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { return ptr.initInspector() catch |err| { log.err("error initializing inspector err={}", .{err}); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index d5302edca..6ca091396 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -4173,7 +4173,8 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( .paste, - text, + &.{.{ .mime = "text/plain", .data = text }}, + &.{}, false, ) catch |err| switch (err) { error.UnsafePaste, @@ -4281,7 +4282,8 @@ const Clipboard = struct { surface.completeClipboardRequest( req.*, - text, + &.{.{ .mime = "text/plain", .data = text }}, + &.{}, true, ) catch |err| { log.warn("failed to complete clipboard request: {}", .{err}); @@ -4339,7 +4341,8 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, - str, + &.{.{ .mime = "text/plain", .data = str }}, + &.{}, false, ) catch |err| switch (err) { error.UnsafePaste, diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 693059473..1fd860bb2 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -120,8 +120,10 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// 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, + /// these representations may be served in the response. The + /// values are sentinel-terminated so they can cross a C apprt + /// boundary without copies. + mimes: []const [:0]const u8, /// True when the targets ('.') listing was requested. list: bool, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 04edd056e..c3f7c3603 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1070,8 +1070,7 @@ pub const StreamHandler = struct { // 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. + // reports an unavailable type anyway. var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined; var mimes_len: usize = 0; var list = false; @@ -1091,7 +1090,10 @@ pub const StreamHandler = struct { // 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 mimes = try alloc.alloc([:0]const u8, mimes_len); + for (mimes_buf[0..mimes_len], mimes) |src, *dst| { + dst.* = try alloc.dupeZ(u8, src); + } const id = try alloc.dupe(u8, meta.id); req.* = .{ // The arena must be copied in last so it tracks every From af9470b19b40b2829653130b6b491c9ecbe6bbee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Aug 2026 07:43:17 -0700 Subject: [PATCH 3/6] macos: Kitty clipboard reads support pw/name session grants --- include/ghostty.h | 36 ++++-- .../ClipboardConfirmationController.swift | 2 + .../ClipboardConfirmationView.swift | 24 +++- .../Terminal/BaseTerminalController.swift | 4 +- macos/Sources/Ghostty/Ghostty.App.swift | 62 +++++------ ...Ghostty.ClipboardConfirmationRequest.swift | 39 +++++-- src/Surface.zig | 104 ++++++++++++------ src/apprt/embedded.zig | 103 +++++++++++------ src/apprt/gtk/class/surface.zig | 18 +-- src/apprt/structs.zig | 14 +++ src/termio/Termio.zig | 9 ++ src/termio/Thread.zig | 4 + src/termio/message.zig | 9 ++ src/termio/stream_handler.zig | 28 ++++- 14 files changed, 316 insertions(+), 140 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 3959f46a6..e467fa0ea 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -85,6 +85,29 @@ typedef struct { size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, @@ -1043,10 +1066,7 @@ typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const ghostty_clipboard_content_s*, - size_t, - const char* const*, - size_t, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1198,12 +1218,8 @@ GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); GHOSTTY_API void ghostty_surface_complete_clipboard_request( ghostty_surface_t, - const ghostty_clipboard_content_s*, - size_t, - const char* const*, - size_t, - void*, - bool); + const ghostty_clipboard_complete_s*, + void*); GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 897ada880..cd91d0824 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -54,6 +54,8 @@ class ClipboardConfirmationController: NSWindowController { window.contentView = NSHostingView(rootView: ClipboardConfirmationView( contents: confirmation.contents, request: confirmation.kind, + programName: confirmation.programName, + canRemember: confirmation.canRemember, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index e4e78b79e..c66e0f0e8 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -2,7 +2,7 @@ import SwiftUI /// This delegate is notified of the completion result of the clipboard confirmation dialog. protocol ClipboardConfirmationViewDelegate: AnyObject { - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool) } /// The SwiftUI view for showing a clipboard confirmation dialog. @@ -31,9 +31,20 @@ struct ClipboardConfirmationView: View { /// The type of the clipboard request let request: Ghostty.ClipboardRequest + /// The human friendly name of the requesting program, when the + /// protocol carries one. + var programName: String? + + /// True when the user's decision may be remembered as a session + /// grant, showing the remember toggle. + var canRemember: Bool = false + /// Optional delegate to get results. If this is nil, then this view will never close on its own. weak var delegate: ClipboardConfirmationViewDelegate? + /// Whether the user's decision should be remembered for the session. + @State private var remember: Bool = false + /// Used to track if we should rehide on disappear @State private var cursorHiddenCount: UInt = 0 @@ -46,7 +57,7 @@ struct ClipboardConfirmationView: View { .padding() .frame(alignment: .center) - Text(request.text()) + Text(request.text(name: programName)) .frame(maxWidth: .infinity, alignment: .leading) .padding() } @@ -55,6 +66,11 @@ struct ClipboardConfirmationView: View { .focusable(false) .font(.system(.body, design: .monospaced)) + if canRemember { + Toggle("Remember this choice for the session", isOn: $remember) + .padding(.top, 4) + } + HStack { Spacer() Button(Action.text(.cancel, request)) { onCancel() } @@ -87,10 +103,10 @@ struct ClipboardConfirmationView: View { } private func onCancel() { - delegate?.clipboardConfirmationComplete(.cancel) + delegate?.clipboardConfirmationComplete(.cancel, remember: false) } private func onPaste() { - delegate?.clipboardConfirmationComplete(.confirm) + delegate?.clipboardConfirmationComplete(.confirm, remember: remember) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 79488106e..b563266d9 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1665,7 +1665,7 @@ extension BaseTerminalController { target.pendingClipboardConfirmation = nil } - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) { + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool) { // End our clipboard confirmation no matter what guard let cc = self.clipboardConfirmation else { return } dismissClipboardConfirmation(cc) @@ -1674,7 +1674,7 @@ extension BaseTerminalController { case .cancel: cc.confirmation.cancel() case .confirm: - cc.confirmation.complete() + cc.confirmation.complete(remember: remember) } // Clear only if this is still the surface's current request. Completing diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 72fe703ab..77a3fa1d7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -68,13 +68,10 @@ extension Ghostty { mimes: mimes, mimesLen: mimesLen, list: list) }, - confirm_read_clipboard_cb: { userdata, contents, contentsLen, available, availableLen, state, request in + confirm_read_clipboard_cb: { userdata, confirm, state, request in App.confirmReadClipboard( userdata, - contents: contents, - contentsLen: contentsLen, - available: available, - availableLen: availableLen, + confirm: confirm, state: state, request: request) }, write_clipboard_cb: { userdata, loc, content, len, confirm in @@ -352,38 +349,37 @@ extension Ghostty { static func confirmReadClipboard( _ userdata: UnsafeMutableRawPointer?, - contents: UnsafePointer?, - contentsLen: Int, - available: UnsafePointer?>?, - availableLen: Int, + confirm: UnsafePointer?, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { return } - guard let kind = Ghostty.ClipboardRequest.from(request: request) else { + guard let confirm, + let kind = Ghostty.ClipboardRequest.from(request: request) else { ghostty_surface_deny_clipboard_request(surface, state) return } + let c = confirm.pointee // Copy the borrowed C representations: the confirmation is // asynchronous and completes with exactly what the user // approved, so the clipboard is never re-read. var reps: [Ghostty.ClipboardContent] = [] - if let contents { - for i in 0.. 0 { - Data(bytes: c.data, count: c.len) + if let contents = c.contents { + for i in 0.. 0 { + Data(bytes: content.data, count: content.len) } else { Data() } - reps.append(.init(mime: String(cString: c.mime), data: data)) + reps.append(.init(mime: String(cString: content.mime), data: data)) } } var avail: [String] = [] - if let available { - for i in 0..] = [] @@ -461,14 +461,14 @@ extension Ghostty { cContents.withUnsafeBufferPointer { contentsBuf in cAvailable.withUnsafeBufferPointer { availableBuf in - ghostty_surface_complete_clipboard_request( - surface, - contentsBuf.baseAddress, - contentsBuf.count, - availableBuf.baseAddress, - availableBuf.count, - state, - confirmed) + var complete = ghostty_clipboard_complete_s( + contents: contentsBuf.baseAddress, + contents_len: contentsBuf.count, + available: availableBuf.baseAddress, + available_len: availableBuf.count, + confirmed: confirmed, + remember: remember) + ghostty_surface_complete_clipboard_request(surface, &complete, state) } } } @@ -521,7 +521,7 @@ extension Ghostty { surface: surfaceView, contents: textPlainString, kind: .osc_52_write - ) { _, confirmed in + ) { _, confirmed, _ in guard confirmed else { return } pasteboard.declareTypes([.string], owner: nil) pasteboard.setString(textPlainString, forType: .string) diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index 89fa61fd3..d41461fa1 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -17,8 +17,11 @@ extension Ghostty { /// the Kitty clipboard protocol (OSC 5522). case kitty_read - /// The text to show in the clipboard confirmation prompt for this request. - func text() -> String { + /// 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. + func text(name: String? = nil) -> String { + let program = name.map { "\"\($0)\"" } ?? "An application" switch self { case .paste: return """ @@ -26,12 +29,12 @@ extension Ghostty { """ case .osc_52_read, .kitty_read: return """ - An application is attempting to read from the clipboard. + \(program) is attempting to read from the clipboard. The current clipboard contents are shown below. """ case .osc_52_write: return """ - An application is attempting to write to the clipboard. + \(program) is attempting to write to the clipboard. The content to write is shown below. """ } @@ -70,18 +73,31 @@ extension Ghostty { let kind: ClipboardRequest - /// Called exactly once with whether the user confirmed the request. - private var completion: ((SurfaceView, Bool) -> Void)? + /// The human friendly name of the requesting program to show in + /// the prompt, when the protocol carries one. + let programName: String? + + /// True when the user's decision may be remembered as a session + /// grant, showing a remember option in the prompt. + let canRemember: Bool + + /// Called exactly once with whether the user confirmed the + /// request and whether their decision should be remembered. + private var completion: ((SurfaceView, Bool, Bool) -> Void)? init( surface: SurfaceView, contents: String, kind: ClipboardRequest, - completion: @escaping (SurfaceView, Bool) -> Void + programName: String? = nil, + canRemember: Bool = false, + completion: @escaping (SurfaceView, Bool, Bool) -> Void ) { self.surface = surface self.contents = contents self.kind = kind + self.programName = programName + self.canRemember = canRemember self.completion = completion } @@ -89,13 +105,13 @@ extension Ghostty { guard let surface, let completion else { return } self.completion = nil DispatchQueue.main.async { - completion(surface, false) + completion(surface, false, false) } } /// Complete the request with the displayed clipboard contents. - func complete() { - finish(true) + func complete(remember: Bool = false) { + finish(true, remember: remember) } /// Cancel the request, denying access to the clipboard contents. @@ -112,6 +128,7 @@ extension Ghostty { private func finish( _ confirmed: Bool, + remember: Bool = false, on explicitSurface: SurfaceView? = nil ) { guard let surface = explicitSurface ?? self.surface, @@ -120,7 +137,7 @@ extension Ghostty { return } self.completion = nil - completion(surface, confirmed) + completion(surface, confirmed, remember) } } } diff --git a/src/Surface.zig b/src/Surface.zig index 4a2b8a335..626d514d5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5847,68 +5847,110 @@ fn writeScreenFile( retain_tmp_dir = true; } +/// The payload for completing a clipboard request with +/// completeClipboardRequest. +pub const CompleteClipboard = struct { + /// The representations the apprt could serve for the request's MIME + /// types. These are immediately copied as needed so they only need + /// to live for the duration of the completion call. Requesters that + /// only carry text (paste, OSC 52) use the first text-like + /// representation. + contents: []const terminal.clipboard.Content = &.{}, + + /// The listing of MIME types available on the clipboard, only + /// gathered when the request asked for it. + available: []const []const u8 = &.{}, + + /// True if any clipboard confirmation prompt was already answered + /// by the user, skipping further prompts: + /// + /// - For "regular" pasting this means that unsafe pastes are + /// allowed. Unsafe data is defined as data that contains + /// newlines, though this definition may change later to detect + /// other scenarios. + /// + /// - For OSC 52 and Kitty clipboard protocol reads and writes no + /// prompt is shown to the user when this is true. + confirmed: bool = false, + + /// True if the user asked to remember their decision. This is only + /// honored by request types that support session grants (Kitty + /// clipboard protocol requests carrying a password). + remember: bool = false, +}; + /// Call this to complete a clipboard request sent to apprt. This should -/// only be called once for each request. All contents are immediately -/// copied so it is safe to free them after this call. +/// only be called once for each request. /// -/// The contents are the representations the apprt could serve for the -/// request's MIME types and `available` is the listing of MIME types on -/// the clipboard (only gathered when the request asked for it). -/// Requesters that only carry text (paste, OSC 52) use the first -/// text-like representation. -/// -/// If `confirmed` is true then any clipboard confirmation prompts are skipped: -/// -/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe -/// data is defined as data that contains newlines, though this definition -/// may change later to detect other scenarios. -/// -/// - For OSC 52 and Kitty clipboard protocol reads and writes no prompt -/// is shown to the user if `confirmed` is true. -/// -/// If `confirmed` is false then this may return either an UnsafePaste or -/// UnauthorizedPaste error, depending on the type of clipboard request. +/// If `complete.confirmed` is false then this may return either an +/// UnsafePaste or UnauthorizedPaste error, depending on the type of +/// clipboard request. The request state remains alive in that case so +/// the apprt can run its confirmation flow. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, - contents: []const terminal.clipboard.Content, - available: []const []const u8, - confirmed: bool, + complete: CompleteClipboard, ) !void { switch (req) { .paste => try self.completeClipboardPaste( - clipboardTextContent(contents) orelse "", - confirmed, + clipboardTextContent(complete.contents) orelse "", + complete.confirmed, ), .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - clipboardTextContent(contents) orelse "", + clipboardTextContent(complete.contents) orelse "", clipboard, - confirmed, + complete.confirmed, ), .osc_52_write => |clipboard| { // The write API wants sentinel-terminated data; the write // text round-tripped through the apprt confirmation flow as // a plain representation. - const data = try self.alloc.dupeZ(u8, clipboardTextContent(contents) orelse ""); + const data = try self.alloc.dupeZ( + u8, + clipboardTextContent(complete.contents) orelse "", + ); defer self.alloc.free(data); try self.rt_surface.setClipboard(clipboard, &.{.{ .mime = "text/plain", .data = data, - }}, !confirmed); + }}, !complete.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) { + // for the confirmation flow. A session grant carried by + // the request skips the prompt. + if (self.config.clipboard_read == .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(); - try self.completeKittyClipboardRead(kitty, contents, available); + + // 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 = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + try self.completeKittyClipboardRead( + kitty, + complete.contents, + complete.available, + ); }, } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 88713174a..16c3d2cc1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -77,14 +77,11 @@ pub const App = struct { /// This may be called after a read clipboard call to request /// confirmation that the clipboard value is safe to read. The /// embedder must call complete_clipboard_request (usually with - /// these same contents, which are only borrowed for this call) or - /// deny_clipboard_request with the given request. + /// the confirmation's contents, which are only borrowed for + /// this call) or deny_clipboard_request with the given request. confirm_read_clipboard: *const fn ( SurfaceUD, - ?[*]const CAPI.ClipboardContent, - usize, - ?[*]const [*:0]const u8, - usize, + *const CAPI.ClipboardConfirm, *apprt.ClipboardRequest, apprt.ClipboardRequestType, ) callconv(.c) void, @@ -767,12 +764,8 @@ pub const Surface = struct { fn completeClipboardRequest( self: *Surface, - contents_: ?[*]const CAPI.ClipboardContent, - contents_len: usize, - available_: ?[*]const [*:0]const u8, - available_len: usize, + complete: *const CAPI.ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { const alloc = self.app.core_app.alloc; @@ -782,7 +775,7 @@ pub const Surface = struct { const conv_alloc = stack.get(); const raw_contents: []const CAPI.ClipboardContent = - if (contents_) |v| v[0..contents_len] else &.{}; + if (complete.contents) |v| v[0..complete.contents_len] else &.{}; const contents = conv_alloc.alloc( terminal.clipboard.Content, raw_contents.len, @@ -798,7 +791,7 @@ pub const Surface = struct { }; const raw_available: []const [*:0]const u8 = - if (available_) |v| v[0..available_len] else &.{}; + if (complete.available) |v| v[0..complete.available_len] else &.{}; const available = conv_alloc.alloc( []const u8, raw_available.len, @@ -814,21 +807,35 @@ pub const Surface = struct { // Attempt to complete the request, but we may request // confirmation. - self.core_surface.completeClipboardRequest( - state.*, - contents, - available, - confirmed, - ) catch |err| switch (err) { + self.core_surface.completeClipboardRequest(state.*, .{ + .contents = contents, + .available = available, + .confirmed = complete.confirmed, + .remember = complete.remember, + }) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, => { + // 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| .{ + if (kitty.name.len > 0) kitty.name.ptr else null, + kitty.pw.len > 0, + }, + else => .{ null, false }, + }; + self.app.opts.confirm_read_clipboard( self.userdata, - contents_, - contents_len, - available_, - available_len, + &.{ + .contents = complete.contents, + .contents_len = complete.contents_len, + .available = complete.available, + .available_len = complete.available_len, + .name = name, + .can_remember = can_remember, + }, state, state.*, ); @@ -1412,6 +1419,41 @@ pub const CAPI = struct { len: usize, }; + // ghostty_clipboard_complete_s + // + // The payload for completing a clipboard read request. See + // Surface.CompleteClipboard for the field documentation. + const ClipboardComplete = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + confirmed: bool, + remember: bool, + }; + + // ghostty_clipboard_confirm_s + // + // The payload of a clipboard read confirmation request: the + // would-be completion contents plus the information shown in the + // permission prompt. All memory is borrowed for the duration of + // the confirm_read_clipboard callback. + const ClipboardConfirm = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + + /// The human friendly name of the requesting program for the + /// prompt, null when the protocol doesn't carry one. + name: ?[*:0]const u8, + + /// True when the user's decision may be remembered as a + /// session grant, reported back through the completion's + /// remember field. + can_remember: bool, + }; + // ghostty_text_s const Text = extern struct { tl_px_x: f64, @@ -2112,21 +2154,10 @@ pub const CAPI = struct { /// instead. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - contents: ?[*]const ClipboardContent, - contents_len: usize, - available: ?[*]const [*:0]const u8, - available_len: usize, + complete: *const ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { - ptr.completeClipboardRequest( - contents, - contents_len, - available, - available_len, - state, - confirmed, - ); + ptr.completeClipboardRequest(complete, state); } /// Deny a clipboard read request started via the read callback, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 6ca091396..709aedfa3 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -4173,9 +4173,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( .paste, - &.{.{ .mime = "text/plain", .data = text }}, - &.{}, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = text }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, @@ -4280,12 +4278,10 @@ const Clipboard = struct { ?[:0]const u8, ) orelse return; - surface.completeClipboardRequest( - req.*, - &.{.{ .mime = "text/plain", .data = text }}, - &.{}, - true, - ) catch |err| { + surface.completeClipboardRequest(req.*, .{ + .contents = &.{.{ .mime = "text/plain", .data = text }}, + .confirmed = true, + }) catch |err| { log.warn("failed to complete clipboard request: {}", .{err}); }; } @@ -4341,9 +4337,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, - &.{.{ .mime = "text/plain", .data = str }}, - &.{}, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = str }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1fd860bb2..825c85939 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -131,6 +131,20 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// 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, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 0f8a7cc4d..ba08ac999 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -717,6 +717,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 { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + try self.terminal_stream.handler.kittyClipboardGrant(pw); +} + pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { self.renderer_state.mutex.lockUncancelable(global.io()); defer self.renderer_state.mutex.unlock(global.io()); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 6b860a0d1..bb842142d 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -336,6 +336,10 @@ fn drainMailbox( } }, .jump_to_prompt => |v| try io.jumpToPrompt(v), + .kitty_clipboard_grant => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw); + }, .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, .focused => |v| try io.focusGained(data, v), diff --git a/src/termio/message.zig b/src/termio/message.zig index e51865e39..f200be4ce 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -82,6 +82,14 @@ pub const Message = union(enum) { /// The surface gained or lost focus. 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, + }, + /// Write where the data fits in the union. write_small: WriteReq.Small, @@ -111,6 +119,7 @@ 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), else => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index c3f7c3603..4609b49e6 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -68,6 +68,10 @@ pub const StreamHandler = struct { /// The tmux control mode viewer state. tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// Session password grants for the Kitty clipboard protocol. + /// Requests carrying a granted password skip the permission prompt. + kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{}, + /// 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. @@ -85,6 +89,7 @@ pub const StreamHandler = struct { pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + self.kitty_clipboard_grants.deinit(self.alloc); if (comptime tmux_enabled) tmux: { const viewer = self.tmux_viewer orelse break :tmux; viewer.deinit(); @@ -869,6 +874,10 @@ pub const StreamHandler = struct { self.terminal.fullReset(); try self.setMouseShape(.text); + // Full reset clears Kitty clipboard session grants. + self.kitty_clipboard_grants.deinit(self.alloc); + self.kitty_clipboard_grants = .{}; + // Reset resets our palette so we report it for mode 2031. self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); @@ -876,6 +885,12 @@ pub const StreamHandler = struct { self.progressReport(.{ .state = .remove }); } + /// 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 queryKittyKeyboard(self: *StreamHandler) !void { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; @@ -1085,9 +1100,11 @@ pub const StreamHandler = struct { 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. + // 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 (meta.name.len > 0) meta.pw else ""; + const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .read); const req = try alloc.create(apprt.ClipboardRequest.KittyRead); const mimes = try alloc.alloc([:0]const u8, mimes_len); @@ -1095,6 +1112,8 @@ pub const StreamHandler = struct { dst.* = try alloc.dupeZ(u8, src); } const id = try alloc.dupe(u8, meta.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, meta.name); req.* = .{ // The arena must be copied in last so it tracks every // allocation above. @@ -1106,6 +1125,9 @@ pub const StreamHandler = struct { .mimes = mimes, .list = list, .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, .terminator = terminator, }; From c1f0ef73a9f3a80673d8be3e9ced5e50afaf65ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Aug 2026 08:12:07 -0700 Subject: [PATCH 4/6] macos: serve copied files as text/uri-list in Kitty clipboard reads --- .../Extensions/NSPasteboard+Extension.swift | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index fb152fa16..fc13c807a 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -54,20 +54,39 @@ extension NSPasteboard { return strings.joined(separator: " ") } + /// The file URLs on the pasteboard, e.g. files copied in Finder. + private var ghosttyFileURLs: [URL] { + (pasteboardItems ?? []).compactMap { item in + guard let plist = item.propertyList(forType: .fileURL), + let url = NSURL(pasteboardPropertyList: plist, ofType: .fileURL) as URL?, + url.isFileURL else { return nil } + return url + } + } + /// The data for the given MIME type, if the pasteboard can serve it. /// /// The canonical "text/plain" type uses the opinionated string /// contents so that e.g. copying a file yields its escaped path; - /// this matches what pasting into the terminal produces. All other + /// this matches what pasting into the terminal produces. Copied + /// files are additionally served as "text/uri-list" (RFC 2483, the + /// type X11/Wayland clipboards carry file copies under). All other /// types are mapped through UTType. func ghosttyData(forMime mime: String) -> Data? { - if mime == "text/plain" { + switch mime { + case "text/plain": guard let str = getOpinionatedStringContents() else { return nil } return Data(str.utf8) - } - guard let type = NSPasteboard.PasteboardType(mimeType: mime) else { return nil } - return data(forType: type) + case "text/uri-list": + let urls = ghosttyFileURLs + guard !urls.isEmpty else { return nil } + return Data(urls.map { $0.absoluteString + "\r\n" }.joined().utf8) + + default: + guard let type = NSPasteboard.PasteboardType(mimeType: mime) else { return nil } + return data(forType: type) + } } /// The MIME types available on the pasteboard, best-effort mapped @@ -84,6 +103,14 @@ extension NSPasteboard { seen.insert("text/plain") } + // 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 { + result.append("text/uri-list") + seen.insert("text/uri-list") + } + for type in types ?? [] { guard let utType = UTType(type.rawValue), let mime = utType.preferredMIMEType, From df14efaf332ab66c2838bed6c3d15ff41f2fd31e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Aug 2026 08:33:37 -0700 Subject: [PATCH 5/6] macos: preview images in the clipboard read confirmation dialog --- .../ClipboardConfirmationController.swift | 1 + .../ClipboardConfirmationView.swift | 18 +++++++++++++++--- macos/Sources/Ghostty/Ghostty.App.swift | 10 +++++++++- .../Ghostty.ClipboardConfirmationRequest.swift | 8 ++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index cd91d0824..cfab6c9b5 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -56,6 +56,7 @@ class ClipboardConfirmationController: NSWindowController { request: confirmation.kind, programName: confirmation.programName, canRemember: confirmation.canRemember, + previewImage: confirmation.previewImage, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index c66e0f0e8..101cf5ac8 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -39,6 +39,10 @@ struct ClipboardConfirmationView: View { /// grant, showing the remember toggle. var canRemember: Bool = false + /// An image decoded from the request contents, shown scaled in + /// place of most of the text area when present. + var previewImage: NSImage? + /// Optional delegate to get results. If this is nil, then this view will never close on its own. weak var delegate: ClipboardConfirmationViewDelegate? @@ -62,9 +66,17 @@ struct ClipboardConfirmationView: View { .padding() } - TextEditor(text: .constant(contents)) - .focusable(false) - .font(.system(.body, design: .monospaced)) + if let previewImage { + Image(nsImage: previewImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal) + } else { + TextEditor(text: .constant(contents)) + .focusable(false) + .font(.system(.body, design: .monospaced)) + } if canRemember { Toggle("Remember this choice for the session", isOn: $remember) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 77a3fa1d7..bf06e3ef7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -391,6 +391,13 @@ extension Ghostty { .flatMap { String(data: $0.data, encoding: .utf8) } ?? reps.map { "\($0.mime) (\($0.data.count) bytes)" }.joined(separator: "\n") + // Decode an image representation so the dialog can preview + // exactly what would be disclosed rather than a byte count. + let previewImage: NSImage? = reps.lazy + .filter { $0.mime.hasPrefix("image/") } + .compactMap { NSImage(data: $0.data) } + .first + // libghostty reaches this callback only when the request attempted // by readClipboard requires confirmation. Reads allowed by policy // complete immediately and never become pending Swift state. @@ -399,7 +406,8 @@ extension Ghostty { contents: display, kind: kind, programName: c.name.map { String(cString: $0) }, - canRemember: c.can_remember + canRemember: c.can_remember, + previewImage: previewImage ) { surfaceView, confirmed, remember in guard let surface = surfaceView.surface else { return } if confirmed { diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index d41461fa1..ee31b179f 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import GhosttyKit @@ -81,6 +82,11 @@ extension Ghostty { /// grant, showing a remember option in the prompt. let canRemember: Bool + /// An image decoded from the request contents, previewed scaled + /// in the dialog when the request carries an image + /// representation. + let previewImage: NSImage? + /// Called exactly once with whether the user confirmed the /// request and whether their decision should be remembered. private var completion: ((SurfaceView, Bool, Bool) -> Void)? @@ -91,6 +97,7 @@ extension Ghostty { kind: ClipboardRequest, programName: String? = nil, canRemember: Bool = false, + previewImage: NSImage? = nil, completion: @escaping (SurfaceView, Bool, Bool) -> Void ) { self.surface = surface @@ -98,6 +105,7 @@ extension Ghostty { self.kind = kind self.programName = programName self.canRemember = canRemember + self.previewImage = previewImage self.completion = completion } From 1bc188739d62f05df33a7bbc9c8db93a1b9f5a13 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Aug 2026 08:39:58 -0700 Subject: [PATCH 6/6] typos --- typos.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/typos.toml b/typos.toml index 46ac28dd8..215848410 100644 --- a/typos.toml +++ b/typos.toml @@ -59,6 +59,7 @@ extend-ignore-re = [ Pn = "Pn" thr = "thr" # Swift oddities +Datas = "Datas" Requestor = "Requestor" iterm = "iterm" ACCES = "ACCES"