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