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,