macos: Kitty clipboard reads serve all clipboard content types

This commit is contained in:
Mitchell Hashimoto
2026-08-23 21:35:02 -07:00
parent 0ce9054bf9
commit 8c7a34d4c9
10 changed files with 454 additions and 102 deletions

View File

@@ -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,

View File

@@ -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<UnsafePointer<CChar>?>?,
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<String>()
if let mimes {
for i in 0..<mimesLen {
guard let ptr = mimes[i] else { continue }
let mime = String(cString: ptr)
guard !seen.contains(mime) else { continue }
seen.insert(mime)
guard let data = pasteboard.ghosttyData(forMime: mime) else { continue }
contents.append(.init(mime: mime, data: data))
}
}
// The listing of available types, only gathered when requested.
let available: [String] = list ? pasteboard.ghosttyAvailableMimes() : []
// With nothing to serve and no listing requested there is
// nothing to complete the read with.
if contents.isEmpty && !list {
return GHOSTTY_CLIPBOARD_READ_UNAVAILABLE
}
completeClipboardRequest(surface, data: str, state: state)
completeClipboardRequest(
surface,
contents: contents,
available: available,
state: state)
return GHOSTTY_CLIPBOARD_READ_STARTED
}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
contents: UnsafePointer<ghostty_clipboard_content_s>?,
contentsLen: Int,
available: UnsafePointer<UnsafePointer<CChar>?>?,
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..<contentsLen {
let c = contents[i]
let data: Data = if c.len > 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..<availableLen {
guard let ptr = available[i] else { continue }
avail.append(String(cString: ptr))
}
}
// The dialog can only display text: show the text
// representation when there is one and summarize the rest.
let display = reps.first(where: { $0.mime == "text/plain" })
.flatMap { String(data: $0.data, encoding: .utf8) }
?? reps.map { "\($0.mime) (\($0.data.count) bytes)" }.joined(separator: "\n")
// 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.
let request = Ghostty.ClipboardConfirmationRequest(
surface: surfaceView,
contents: valueStr,
contents: display,
kind: kind
) { surfaceView, contents in
) { surfaceView, confirmed in
guard let surface = surfaceView.surface else { return }
completeClipboardRequest(
surface,
data: contents,
state: state,
confirmed: true)
if confirmed {
completeClipboardRequest(
surface,
contents: reps,
available: avail,
state: state,
confirmed: true)
} else {
ghostty_surface_deny_clipboard_request(surface, state)
}
}
surfaceView.pendingClipboardConfirmation = request
}
private static func completeClipboardRequest(
_ surface: ghostty_surface_t,
data: String?,
contents: [Ghostty.ClipboardContent],
available: [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
// Copy everything into C memory for the duration of the call.
var cStrings: [UnsafeMutablePointer<CChar>] = []
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<CChar>?] = []
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
}

View File

@@ -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)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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<String>()
// 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 {

View File

@@ -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);

View File

@@ -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});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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