mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 00:51:44 +00:00
macos: Kitty clipboard reads serve all clipboard content types
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user