macOS: Kitty clipboard read support (#13993)

This adds Kitty clipboard protocol _read_ support to macOS. In the
process, this also does most of the core termio, apprt, and Surface work
so GTK is likely very easy to do, I just didn't have the machine on hand
to test at the given moment. I will create an issue to follow up with
that.

This fully supports:

- Non-text data, like images! For this, we show an image preview.
- Per-program "remember"
- Showing the program name if given instead of generic "An application"

<img width="1848" height="996" alt="CleanShot 2026-08-24 at 08 37 45@2x"
src="https://github.com/user-attachments/assets/549d9031-2e98-46bf-90d4-94171b255c42"
/>
This commit is contained in:
Mitchell Hashimoto
2026-08-24 09:00:58 -07:00
committed by GitHub
21 changed files with 1139 additions and 167 deletions

View File

@@ -77,17 +77,51 @@ 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;
// The payload for completing a clipboard read request. See
// ghostty_surface_complete_clipboard_request.
typedef struct {
const ghostty_clipboard_content_s *contents;
size_t contents_len;
const char *const *available;
size_t available_len;
bool confirmed;
bool remember;
} ghostty_clipboard_complete_s;
// The payload of a clipboard read confirmation request: the would-be
// completion contents plus the information shown in the permission
// prompt. See ghostty_runtime_confirm_read_clipboard_cb.
typedef struct {
const ghostty_clipboard_content_s *contents;
size_t contents_len;
const char *const *available;
size_t available_len;
const char *name;
bool can_remember;
} ghostty_clipboard_confirm_s;
typedef enum {
GHOSTTY_CLIPBOARD_REQUEST_PASTE,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ,
} ghostty_clipboard_request_e;
// apprt.ClipboardReadResult
typedef enum {
GHOSTTY_CLIPBOARD_READ_STARTED,
GHOSTTY_CLIPBOARD_READ_UNAVAILABLE,
GHOSTTY_CLIPBOARD_READ_UNSUPPORTED,
} ghostty_clipboard_read_result_e;
typedef enum {
GHOSTTY_MOUSE_RELEASE,
GHOSTTY_MOUSE_PRESS,
@@ -1023,12 +1057,16 @@ typedef struct {
} ghostty_action_s;
typedef void (*ghostty_runtime_wakeup_cb)(void*);
typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
ghostty_clipboard_e,
void*);
typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)(
void*,
ghostty_clipboard_e,
void*,
const char* const*,
size_t,
bool);
typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
void*,
const char*,
const ghostty_clipboard_confirm_s*,
void*,
ghostty_clipboard_request_e);
typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
@@ -1178,10 +1216,12 @@ 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_complete_s*,
void*);
GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t,
void*);
GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t);
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

@@ -47,13 +47,16 @@ class ClipboardConfirmationController: NSWindowController {
switch confirmation.kind {
case .paste:
window.title = "Warning: Potentially Unsafe Paste"
case .osc_52_read, .osc_52_write:
case .osc_52_read, .osc_52_write, .kitty_read:
window.title = "Authorize Clipboard Access"
}
window.contentView = NSHostingView(rootView: ClipboardConfirmationView(
contents: confirmation.contents,
request: confirmation.kind,
programName: confirmation.programName,
canRemember: confirmation.canRemember,
previewImage: confirmation.previewImage,
delegate: delegate
))
}

View File

@@ -2,7 +2,7 @@ import SwiftUI
/// This delegate is notified of the completion result of the clipboard confirmation dialog.
protocol ClipboardConfirmationViewDelegate: AnyObject {
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action)
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool)
}
/// The SwiftUI view for showing a clipboard confirmation dialog.
@@ -15,11 +15,11 @@ struct ClipboardConfirmationView: View {
switch (action, reason) {
case (.cancel, .paste):
return "Cancel"
case (.cancel, .osc_52_read), (.cancel, .osc_52_write):
case (.cancel, .osc_52_read), (.cancel, .osc_52_write), (.cancel, .kitty_read):
return "Deny"
case (.confirm, .paste):
return "Paste"
case (.confirm, .osc_52_read), (.confirm, .osc_52_write):
case (.confirm, .osc_52_read), (.confirm, .osc_52_write), (.confirm, .kitty_read):
return "Allow"
}
}
@@ -31,9 +31,24 @@ struct ClipboardConfirmationView: View {
/// The type of the clipboard request
let request: Ghostty.ClipboardRequest
/// The human friendly name of the requesting program, when the
/// protocol carries one.
var programName: String?
/// True when the user's decision may be remembered as a session
/// grant, showing the remember toggle.
var canRemember: Bool = false
/// An image decoded from the request contents, shown scaled in
/// place of most of the text area when present.
var previewImage: NSImage?
/// Optional delegate to get results. If this is nil, then this view will never close on its own.
weak var delegate: ClipboardConfirmationViewDelegate?
/// Whether the user's decision should be remembered for the session.
@State private var remember: Bool = false
/// Used to track if we should rehide on disappear
@State private var cursorHiddenCount: UInt = 0
@@ -46,14 +61,27 @@ struct ClipboardConfirmationView: View {
.padding()
.frame(alignment: .center)
Text(request.text())
Text(request.text(name: programName))
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
TextEditor(text: .constant(contents))
.focusable(false)
.font(.system(.body, design: .monospaced))
if let previewImage {
Image(nsImage: previewImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal)
} else {
TextEditor(text: .constant(contents))
.focusable(false)
.font(.system(.body, design: .monospaced))
}
if canRemember {
Toggle("Remember this choice for the session", isOn: $remember)
.padding(.top, 4)
}
HStack {
Spacer()
@@ -87,10 +115,10 @@ struct ClipboardConfirmationView: View {
}
private func onCancel() {
delegate?.clipboardConfirmationComplete(.cancel)
delegate?.clipboardConfirmationComplete(.cancel, remember: false)
}
private func onPaste() {
delegate?.clipboardConfirmationComplete(.confirm)
delegate?.clipboardConfirmationComplete(.confirm, remember: remember)
}
}

View File

@@ -1665,7 +1665,7 @@ extension BaseTerminalController {
target.pendingClipboardConfirmation = nil
}
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) {
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
dismissClipboardConfirmation(cc)
@@ -1674,7 +1674,7 @@ extension BaseTerminalController {
case .cancel:
cc.confirmation.cancel()
case .confirm:
cc.confirmation.complete()
cc.confirmation.complete(remember: remember)
}
// Clear only if this is still the surface's current request. Completing

View File

@@ -60,8 +60,20 @@ 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, confirm, state, request in
App.confirmReadClipboard(
userdata,
confirm: confirm,
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,60 +298,186 @@ extension Ghostty {
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
state: UnsafeMutableRawPointer?
) -> Bool {
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 { return false }
guard let surface = surfaceView.surface else {
return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED
}
// Get our pasteboard
guard let pasteboard = NSPasteboard.ghostty(location) else { return false }
guard let pasteboard = NSPasteboard.ghostty(location) else {
return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED
}
// Return false if there is no text-like clipboard content so
// performable paste bindings can pass through to the terminal.
guard let str = pasteboard.getOpinionatedStringContents() else { return false }
// 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))
}
}
completeClipboardRequest(surface, data: str, state: state)
return true
// 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,
contents: contents,
available: available,
state: state)
return GHOSTTY_CLIPBOARD_READ_STARTED
}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
confirm: UnsafePointer<ghostty_clipboard_confirm_s>?,
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 confirm,
let kind = Ghostty.ClipboardRequest.from(request: request) else {
ghostty_surface_deny_clipboard_request(surface, state)
return
}
let c = confirm.pointee
// Copy the borrowed C representations: the confirmation is
// asynchronous and completes with exactly what the user
// approved, so the clipboard is never re-read.
var reps: [Ghostty.ClipboardContent] = []
if let contents = c.contents {
for i in 0..<c.contents_len {
let content = contents[i]
let data: Data = if content.len > 0 {
Data(bytes: content.data, count: content.len)
} else {
Data()
}
reps.append(.init(mime: String(cString: content.mime), data: data))
}
}
var avail: [String] = []
if let available = c.available {
for i in 0..<c.available_len {
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")
// Decode an image representation so the dialog can preview
// exactly what would be disclosed rather than a byte count.
let previewImage: NSImage? = reps.lazy
.filter { $0.mime.hasPrefix("image/") }
.compactMap { NSImage(data: $0.data) }
.first
// libghostty reaches this callback only when the request attempted
// by readClipboard requires confirmation. Reads allowed by policy
// complete immediately and never become pending Swift state.
let request = Ghostty.ClipboardConfirmationRequest(
surface: surfaceView,
contents: valueStr,
kind: kind
) { surfaceView, contents in
contents: display,
kind: kind,
programName: c.name.map { String(cString: $0) },
canRemember: c.can_remember,
previewImage: previewImage
) { surfaceView, confirmed, remember 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,
remember: remember)
} 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
confirmed: Bool = false,
remember: Bool = false
) {
data.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed)
// 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() }
}
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
var complete = ghostty_clipboard_complete_s(
contents: contentsBuf.baseAddress,
contents_len: contentsBuf.count,
available: availableBuf.baseAddress,
available_len: availableBuf.count,
confirmed: confirmed,
remember: remember)
ghostty_surface_complete_clipboard_request(surface, &complete, state)
}
}
}
@@ -376,24 +514,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

@@ -1,3 +1,4 @@
import AppKit
import Foundation
import GhosttyKit
@@ -13,21 +14,28 @@ extension Ghostty {
/// An application is attempting to write to the clipboard using OSC 52.
case osc_52_write
/// The text to show in the clipboard confirmation prompt for this request.
func text() -> String {
/// An application is attempting to read from the clipboard using
/// the Kitty clipboard protocol (OSC 5522).
case kitty_read
/// The text to show in the clipboard confirmation prompt for this
/// request. The name is the requesting program's human friendly
/// name, when the protocol carries one.
func text(name: String? = nil) -> String {
let program = name.map { "\"\($0)\"" } ?? "An application"
switch self {
case .paste:
return """
Pasting this text to the terminal may be dangerous as it looks like some commands may be executed.
"""
case .osc_52_read:
case .osc_52_read, .kitty_read:
return """
An application is attempting to read from the clipboard.
\(program) is attempting to read from the clipboard.
The current clipboard contents are shown below.
"""
case .osc_52_write:
return """
An application is attempting to write to the clipboard.
\(program) is attempting to write to the clipboard.
The content to write is shown below.
"""
}
@@ -41,6 +49,8 @@ extension Ghostty {
return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write
case GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ:
return .kitty_read
default:
return nil
}
@@ -56,20 +66,46 @@ 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)?
/// The human friendly name of the requesting program to show in
/// the prompt, when the protocol carries one.
let programName: String?
/// True when the user's decision may be remembered as a session
/// grant, showing a remember option in the prompt.
let canRemember: Bool
/// An image decoded from the request contents, previewed scaled
/// in the dialog when the request carries an image
/// representation.
let previewImage: NSImage?
/// Called exactly once with whether the user confirmed the
/// request and whether their decision should be remembered.
private var completion: ((SurfaceView, Bool, Bool) -> Void)?
init(
surface: SurfaceView,
contents: String,
kind: ClipboardRequest,
completion: @escaping (SurfaceView, String?) -> Void
programName: String? = nil,
canRemember: Bool = false,
previewImage: NSImage? = nil,
completion: @escaping (SurfaceView, Bool, Bool) -> Void
) {
self.surface = surface
self.contents = contents
self.kind = kind
self.programName = programName
self.canRemember = canRemember
self.previewImage = previewImage
self.completion = completion
}
@@ -77,29 +113,30 @@ extension Ghostty {
guard let surface, let completion else { return }
self.completion = nil
DispatchQueue.main.async {
completion(surface, nil)
completion(surface, false, false)
}
}
/// Complete the request using the displayed clipboard contents.
func complete() {
finish(contents)
/// Complete the request with the displayed clipboard contents.
func complete(remember: Bool = false) {
finish(true, remember: remember)
}
/// 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,
remember: Bool = false,
on explicitSurface: SurfaceView? = nil
) {
guard let surface = explicitSurface ?? self.surface,
@@ -108,7 +145,7 @@ extension Ghostty {
return
}
self.completion = nil
completion(surface, contents)
completion(surface, confirmed, remember)
}
}
}

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,74 @@ extension NSPasteboard {
return strings.joined(separator: " ")
}
/// The file URLs on the pasteboard, e.g. files copied in Finder.
private var ghosttyFileURLs: [URL] {
(pasteboardItems ?? []).compactMap { item in
guard let plist = item.propertyList(forType: .fileURL),
let url = NSURL(pasteboardPropertyList: plist, ofType: .fileURL) as URL?,
url.isFileURL else { return nil }
return url
}
}
/// The data for the given MIME type, if the pasteboard can serve it.
///
/// The canonical "text/plain" type uses the opinionated string
/// contents so that e.g. copying a file yields its escaped path;
/// this matches what pasting into the terminal produces. Copied
/// files are additionally served as "text/uri-list" (RFC 2483, the
/// type X11/Wayland clipboards carry file copies under). All other
/// types are mapped through UTType.
func ghosttyData(forMime mime: String) -> Data? {
switch mime {
case "text/plain":
guard let str = getOpinionatedStringContents() else { return nil }
return Data(str.utf8)
case "text/uri-list":
let urls = ghosttyFileURLs
guard !urls.isEmpty else { return nil }
return Data(urls.map { $0.absoluteString + "\r\n" }.joined().utf8)
default:
guard let type = NSPasteboard.PasteboardType(mimeType: mime) else { return nil }
return data(forType: type)
}
}
/// The MIME types available on the pasteboard, best-effort mapped
/// 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")
}
// Copied files are additionally served as a URI list. The
// generic mapping below never reports this since file URL
// pasteboard types have no MIME type.
if !ghosttyFileURLs.isEmpty {
result.append("text/uri-list")
seen.insert("text/uri-list")
}
for type in types ?? [] {
guard let utType = UTType(type.rawValue),
let mime = utType.preferredMIMEType,
!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

@@ -1061,6 +1061,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
_ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
},
.kitty_clipboard_read => |req| try self.kittyClipboardRead(req),
.clipboard_write => |w| switch (w.req) {
.small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type),
.stable => |v| try self.clipboardWrite(v, w.clipboard_type),
@@ -5107,15 +5109,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.paste_from_clipboard => return try self.startClipboardRequest(
.paste_from_clipboard => return (try self.startClipboardRequest(
.standard,
.{ .paste = {} },
),
)) == .started,
.paste_from_selection => return try self.startClipboardRequest(
.paste_from_selection => return (try self.startClipboardRequest(
.selection,
.{ .paste = {} },
),
)) == .started,
.increase_font_size => |delta| {
// Max delta is somewhat arbitrary.
@@ -5845,54 +5847,165 @@ fn writeScreenFile(
retain_tmp_dir = true;
}
/// The payload for completing a clipboard request with
/// completeClipboardRequest.
pub const CompleteClipboard = struct {
/// The representations the apprt could serve for the request's MIME
/// types. These are immediately copied as needed so they only need
/// to live for the duration of the completion call. Requesters that
/// only carry text (paste, OSC 52) use the first text-like
/// representation.
contents: []const terminal.clipboard.Content = &.{},
/// The listing of MIME types available on the clipboard, only
/// gathered when the request asked for it.
available: []const []const u8 = &.{},
/// True if any clipboard confirmation prompt was already answered
/// by the user, skipping further prompts:
///
/// - For "regular" pasting this means that unsafe pastes are
/// allowed. Unsafe data is defined as data that contains
/// newlines, though this definition may change later to detect
/// other scenarios.
///
/// - For OSC 52 and Kitty clipboard protocol reads and writes no
/// prompt is shown to the user when this is true.
confirmed: bool = false,
/// True if the user asked to remember their decision. This is only
/// honored by request types that support session grants (Kitty
/// clipboard protocol requests carrying a password).
remember: bool = false,
};
/// Call this to complete a clipboard request sent to apprt. This should
/// only be called once for each request. The data is immediately copied so
/// it is safe to free the data after this call.
/// only be called once for each request.
///
/// If `confirmed` is true then any clipboard confirmation prompts are skipped:
///
/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe
/// data is defined as data that contains newlines, though this definition
/// may change later to detect other scenarios.
///
/// - For OSC 52 reads and writes no prompt is shown to the user if
/// `confirmed` is true.
///
/// If `confirmed` is false then this may return either an UnsafePaste or
/// UnauthorizedPaste error, depending on the type of clipboard request.
/// If `complete.confirmed` is false then this may return either an
/// UnsafePaste or UnauthorizedPaste error, depending on the type of
/// clipboard request. The request state remains alive in that case so
/// the apprt can run its confirmation flow.
pub fn completeClipboardRequest(
self: *Surface,
req: apprt.ClipboardRequest,
data: [:0]const u8,
confirmed: bool,
complete: CompleteClipboard,
) !void {
switch (req) {
.paste => try self.completeClipboardPaste(data, confirmed),
.osc_52_read => |clipboard| try self.completeClipboardReadOSC52(
data,
clipboard,
confirmed,
.paste => try self.completeClipboardPaste(
clipboardTextContent(complete.contents) orelse "",
complete.confirmed,
),
.osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{
.mime = "text/plain",
.data = data,
}}, !confirmed),
.osc_52_read => |clipboard| try self.completeClipboardReadOSC52(
clipboardTextContent(complete.contents) orelse "",
clipboard,
complete.confirmed,
),
.osc_52_write => |clipboard| {
// The write API wants sentinel-terminated data; the write
// text round-tripped through the apprt confirmation flow as
// a plain representation.
const data = try self.alloc.dupeZ(
u8,
clipboardTextContent(complete.contents) orelse "",
);
defer self.alloc.free(data);
try self.rt_surface.setClipboard(clipboard, &.{.{
.mime = "text/plain",
.data = data,
}}, !complete.confirmed);
},
.kitty_read => |kitty| {
// If we need confirmation we return an error without
// consuming the request state; the apprt keeps it alive
// for the confirmation flow. A session grant carried by
// the request skips the prompt.
if (self.config.clipboard_read == .ask and
!complete.confirmed and
!kitty.granted)
{
return error.UnauthorizedPaste;
}
// Past the confirmation check the request is consumed:
// every path from here, including errors, must destroy it.
defer kitty.destroy();
// Record a session grant when the user asked to remember
// their decision and the request carried a usable
// password. The grants live with the terminal state on
// the IO thread.
if (complete.remember and kitty.pw.len > 0) {
const pw = try self.alloc.dupe(u8, kitty.pw);
self.queueIo(.{ .kitty_clipboard_grant = .{
.alloc = self.alloc,
.pw = pw,
} }, .unlocked);
}
try self.completeKittyClipboardRead(
kitty,
complete.contents,
complete.available,
);
},
}
}
/// 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.
pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void {
switch (req) {
// A denied paste simply doesn't happen.
.paste => {},
// OSC 52 has no error responses, but the client is waiting on
// a reply, so a denied read is answered with empty contents.
.osc_52_read => |clipboard| self.completeClipboardReadOSC52(
"",
clipboard,
true,
) catch |err| {
log.warn("error replying to OSC 52 clipboard read err={}", .{err});
},
// A denied write simply doesn't happen.
.osc_52_write => {},
// The Kitty clipboard protocol reports denial explicitly.
.kitty_read => |kitty| {
defer kitty.destroy();
self.kittyClipboardReadStatus(kitty, .EPERM) catch |err| {
log.warn("error replying to kitty clipboard read err={}", .{err});
};
},
}
}
/// This starts a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
///
/// Returns true if the request was started, false if it was not (e.g., clipboard
/// doesn't contain text for paste requests). This allows performable keybinds
/// to pass through when the action cannot be performed.
/// The result reports whether the request was started; requests that
/// weren't started never complete. Callers own reacting to that, e.g.
/// performable paste keybinds pass through and Kitty clipboard reads
/// answer the program.
fn startClipboardRequest(
self: *Surface,
loc: apprt.Clipboard,
req: apprt.ClipboardRequest,
) !bool {
) !apprt.ClipboardReadResult {
switch (req) {
.paste => {}, // always allowed
.osc_52_read => if (self.config.clipboard_read == .deny) {
@@ -5900,9 +6013,13 @@ fn startClipboardRequest(
"application attempted to read clipboard, but 'clipboard-read' is set to deny",
.{},
);
return false;
return .unsupported;
},
// The clipboard-read policy was already applied by
// kittyClipboardRead, which owns replying on denial.
.kitty_read => {},
// No clipboard write code paths travel through this function
.osc_52_write => unreachable,
}
@@ -6034,6 +6151,129 @@ fn completeClipboardReadOSC52(
} }, .unlocked);
}
/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded
/// by the IO thread. This takes ownership of the request state.
fn kittyClipboardRead(
self: *Surface,
req: *apprt.ClipboardRequest.KittyRead,
) !void {
// A read denied by policy answers EPERM so clients degrade
// gracefully instead of waiting on a response that never comes.
if (self.config.clipboard_read == .deny) {
defer req.destroy();
log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{});
try self.kittyClipboardReadStatus(req, .EPERM);
return;
}
const result = self.startClipboardRequest(
req.location,
.{ .kitty_read = req },
) catch |err| {
defer req.destroy();
self.kittyClipboardReadStatus(req, .EIO) catch {};
return err;
};
switch (result) {
// The request completes asynchronously.
.started => {},
// The clipboard has nothing we can serve, which is a
// successful read that serves no representations. This never
// prompts even under an ask policy since there are no contents
// to disclose.
.unavailable => {
defer req.destroy();
try self.completeKittyClipboardRead(req, &.{}, &.{});
},
// The apprt can't serve this clipboard at all, e.g. an
// unsupported primary selection.
.unsupported => {
defer req.destroy();
try self.kittyClipboardReadStatus(req, .ENOSYS);
},
}
}
/// Reply to a Kitty clipboard read with a single status packet.
fn kittyClipboardReadStatus(
self: *Surface,
req: *const apprt.ClipboardRequest.KittyRead,
status: terminal.kitty.clipboard.Status,
) !void {
var aw: std.Io.Writer.Allocating = .init(self.alloc);
defer aw.deinit();
try (terminal.kitty.clipboard.Response{
.op = .read,
.status = status,
.id = req.id,
.terminator = req.terminator,
}).encode(&aw.writer);
self.queueIo(.{ .write_alloc = .{
.alloc = self.alloc,
.data = try aw.toOwnedSlice(),
} }, .unlocked);
}
/// Complete a Kitty clipboard protocol read with the clipboard
/// contents.
fn completeKittyClipboardRead(
self: *Surface,
req: *const apprt.ClipboardRequest.KittyRead,
contents: []const terminal.clipboard.Content,
available: []const []const u8,
) !void {
const kitty_clipboard = terminal.kitty.clipboard;
// 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| {
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, 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 = available,
.contents = contents_buf[0..contents_len],
.terminator = req.terminator,
}).encode(&aw.writer);
self.queueIo(.{ .write_alloc = .{
.alloc = self.alloc,
.data = try aw.toOwnedSlice(),
} }, .unlocked);
}
fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void {
// Wyhash is used to hash the contents of the desktop notification to limit
// how fast identical notifications can be sent sequentially.

View File

@@ -27,6 +27,7 @@ pub const Target = action.Target;
pub const ContentScale = structs.ContentScale;
pub const Clipboard = structs.Clipboard;
pub const ClipboardContent = structs.ClipboardContent;
pub const ClipboardReadResult = structs.ClipboardReadResult;
pub const ClipboardRequest = structs.ClipboardRequest;
pub const ClipboardRequestType = structs.ClipboardRequestType;
pub const ColorScheme = structs.ColorScheme;

View File

@@ -52,18 +52,36 @@ pub const App = struct {
/// Callback called to handle an action.
action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool,
/// Read the clipboard value. Returns true if the clipboard request
/// was started and complete_clipboard_request may be called with the
/// given state pointer. Returns false if the clipboard request couldn't
/// be started (such as when no text is available for a paste request).
read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool,
/// Read the clipboard value. The result only reports facts about
/// the clipboard: whether the request was started (in which case
/// complete_clipboard_request 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
/// the confirmation's contents, which are only borrowed for
/// this call) or deny_clipboard_request with the given request.
confirm_read_clipboard: *const fn (
SurfaceUD,
[*:0]const u8,
*const CAPI.ClipboardConfirm,
*apprt.ClipboardRequest,
apprt.ClipboardRequestType,
) callconv(.c) void,
@@ -692,7 +710,35 @@ pub const Surface = struct {
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !bool {
) !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
@@ -702,40 +748,94 @@ pub const Surface = struct {
errdefer alloc.destroy(state_ptr);
state_ptr.* = state;
const started = self.app.opts.read_clipboard(
const result = self.app.opts.read_clipboard(
self.userdata,
@intCast(@intFromEnum(clipboard_type)),
state_ptr,
mimes.ptr,
mimes.len,
list,
);
if (!started) {
alloc.destroy(state_ptr);
return false;
}
return true;
// Only a started request completes later and keeps the state.
if (result != .started) alloc.destroy(state_ptr);
return result;
}
fn completeClipboardRequest(
self: *Surface,
str: [:0]const u8,
complete: *const CAPI.ClipboardComplete,
state: *apprt.ClipboardRequest,
confirmed: bool,
) void {
const alloc = self.app.core_app.alloc;
// 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 (complete.contents) |v| v[0..complete.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 (complete.available) |v| v[0..complete.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,
confirmed,
) catch |err| switch (err) {
self.core_surface.completeClipboardRequest(state.*, .{
.contents = contents,
.available = available,
.confirmed = complete.confirmed,
.remember = complete.remember,
}) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
=> {
// Session grant information for the permission prompt,
// carried only by Kitty clipboard protocol requests.
const name: ?[*:0]const u8, const can_remember: bool = switch (state.*) {
.kitty_read => |kitty| .{
if (kitty.name.len > 0) kitty.name.ptr else null,
kitty.pw.len > 0,
},
else => .{ null, false },
};
self.app.opts.confirm_read_clipboard(
self.userdata,
str.ptr,
&.{
.contents = complete.contents,
.contents_len = complete.contents_len,
.available = complete.available,
.available_len = complete.available_len,
.name = name,
.can_remember = can_remember,
},
state,
state.*,
);
@@ -751,6 +851,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,
@@ -763,7 +871,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,
};
}
@@ -1301,9 +1410,48 @@ 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_clipboard_complete_s
//
// The payload for completing a clipboard read request. See
// Surface.CompleteClipboard for the field documentation.
const ClipboardComplete = extern struct {
contents: ?[*]const ClipboardContent,
contents_len: usize,
available: ?[*]const [*:0]const u8,
available_len: usize,
confirmed: bool,
remember: bool,
};
// ghostty_clipboard_confirm_s
//
// The payload of a clipboard read confirmation request: the
// would-be completion contents plus the information shown in the
// permission prompt. All memory is borrowed for the duration of
// the confirm_read_clipboard callback.
const ClipboardConfirm = extern struct {
contents: ?[*]const ClipboardContent,
contents_len: usize,
available: ?[*]const [*:0]const u8,
available_len: usize,
/// The human friendly name of the requesting program for the
/// prompt, null when the protocol doesn't carry one.
name: ?[*:0]const u8,
/// True when the user's decision may be remembered as a
/// session grant, reported back through the completion's
/// remember field.
can_remember: bool,
};
// ghostty_text_s
@@ -1995,20 +2143,32 @@ 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.
///
/// To deny a request use ghostty_surface_deny_clipboard_request
/// instead.
export fn ghostty_surface_complete_clipboard_request(
ptr: *Surface,
str: [*:0]const u8,
complete: *const ClipboardComplete,
state: *apprt.ClipboardRequest,
confirmed: bool,
) void {
ptr.completeClipboardRequest(
std.mem.sliceTo(str, 0),
state,
confirmed,
);
ptr.completeClipboardRequest(complete, state);
}
/// 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 {

View File

@@ -74,7 +74,7 @@ pub fn clipboardRequest(
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !bool {
) !apprt.ClipboardReadResult {
return try self.surface.clipboardRequest(
clipboard_type,
state,

View File

@@ -197,7 +197,7 @@ pub const ClipboardConfirmationDialog = extern struct {
self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below."));
},
.osc_52_read => {
.osc_52_read, .kitty_read => {
self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below."));
},

View File

@@ -1725,7 +1725,7 @@ pub const Surface = extern struct {
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !bool {
) !apprt.ClipboardReadResult {
return try Clipboard.request(
self,
clipboard_type,
@@ -4111,22 +4111,23 @@ const Clipboard = struct {
);
}
/// Request data from the clipboard (read the clipboard). This
/// completes asynchronously and will call the `completeClipboardRequest`
/// core surface API when done.
///
/// Returns true if the request was started, false if the clipboard
/// doesn't contain text (allowing performable keybinds to pass through).
/// Request data from the clipboard (read the clipboard). A started
/// request completes asynchronously and will call the
/// `completeClipboardRequest` core surface API when done.
pub fn request(
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) Allocator.Error!bool {
) Allocator.Error!apprt.ClipboardReadResult {
// The GTK apprt doesn't support Kitty clipboard protocol reads
// yet.
if (state == .kitty_read) return .unsupported;
// Get our requested clipboard
const clipboard = get(
self.private().gl_area.as(gtk.Widget),
clipboard_type,
) orelse return false;
) orelse return .unsupported;
// For paste requests, check if clipboard has text format available.
// This is a synchronous check that allows performable keybinds to
@@ -4135,7 +4136,7 @@ const Clipboard = struct {
const formats = clipboard.getFormats();
if (formats.containGtype(gobject.ext.types.string) == 0) {
log.debug("clipboard has no text format, not starting paste request", .{});
return false;
return .unavailable;
}
}
@@ -4158,7 +4159,7 @@ const Clipboard = struct {
ud,
);
return true;
return .started;
}
/// Paste explicit text directly into the surface, regardless of the
@@ -4172,8 +4173,7 @@ const Clipboard = struct {
const surface = self.private().core_surface orelse return;
surface.completeClipboardRequest(
.paste,
text,
false,
.{ .contents = &.{.{ .mime = "text/plain", .data = text }} },
) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
@@ -4224,7 +4224,7 @@ const Clipboard = struct {
.request = &req,
.@"can-remember" = switch (req) {
.osc_52_read, .osc_52_write => true,
.paste => false,
.paste, .kitty_read => false,
},
.@"clipboard-contents" = contents_buf,
},
@@ -4261,7 +4261,7 @@ const Clipboard = struct {
if (remember) switch (req.*) {
.osc_52_read => surface.config.clipboard_read = .allow,
.osc_52_write => surface.config.clipboard_write = .allow,
.paste => {},
.paste, .kitty_read => {},
};
// Get our text
@@ -4278,11 +4278,10 @@ const Clipboard = struct {
?[:0]const u8,
) orelse return;
surface.completeClipboardRequest(
req.*,
text,
true,
) catch |err| {
surface.completeClipboardRequest(req.*, .{
.contents = &.{.{ .mime = "text/plain", .data = text }},
.confirmed = true,
}) catch |err| {
log.warn("failed to complete clipboard request: {}", .{err});
};
}
@@ -4300,7 +4299,7 @@ const Clipboard = struct {
if (remember) switch (req.*) {
.osc_52_read => surface.config.clipboard_read = .deny,
.osc_52_write => surface.config.clipboard_write = .deny,
.paste => @panic("paste should not be able to be remembered"),
.paste, .kitty_read => @panic("request should not be able to be remembered"),
};
}
@@ -4338,8 +4337,7 @@ const Clipboard = struct {
const surface = self.private().core_surface orelse return;
surface.completeClipboardRequest(
req.state,
str,
false,
.{ .contents = &.{.{ .mime = "text/plain", .data = str }} },
) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,

View File

@@ -1,4 +1,6 @@
const std = @import("std");
const build_config = @import("../build_config.zig");
const terminal = @import("../terminal/main.zig");
/// ContentScale is the ratio between the current DPI and the platform's
/// default DPI. This is used to determine how much certain rendered elements
@@ -67,6 +69,26 @@ pub const ClipboardRequestType = enum(u8) {
paste,
osc_52_read,
osc_52_write,
kitty_read,
};
/// The result of starting a clipboard read request. This only reports
/// facts about the clipboard; how each state is answered on the wire
/// (if at all) is up to the protocol handling of the requester.
///
/// If this is changed, you must also update ghostty.h
pub const ClipboardReadResult = enum(c_int) {
/// The request was started and will be completed asynchronously
/// via the core surface completeClipboardRequest API.
started = 0,
/// The clipboard exists but has no contents the apprt can serve
/// (e.g. no text-like data). The request was not started.
unavailable = 1,
/// The clipboard itself can't be read (e.g. a primary selection on
/// a platform without one). The request was not started.
unsupported = 2,
};
/// Clipboard request. This is used to request clipboard contents and must
@@ -81,6 +103,59 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
/// A request to write clipboard contents via OSC 52.
osc_52_write: Clipboard,
/// A request to read clipboard contents via the Kitty clipboard
/// protocol (OSC 5522).
kitty_read: *KittyRead,
/// State for one in-flight Kitty clipboard protocol read. This is
/// created on the IO thread and completed on the app thread, so it
/// owns all of its memory: everything, including the struct itself,
/// is allocated from the arena.
pub const KittyRead = struct {
arena: std.heap.ArenaAllocator,
/// The clipboard being read. The protocol can only name the
/// standard clipboard or the primary selection.
location: Clipboard,
/// The requested MIME types in request order, already capped at
/// terminal.kitty.clipboard.max_read_mimes by the sender. Only
/// these representations may be served in the response. 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,
/// The sanitized request id, echoed in every response packet.
id: []const u8,
/// The effective session password, empty when the request had
/// none. A non-empty password means the user's decision may be
/// remembered as a session grant.
pw: []const u8,
/// The human friendly name of the requesting program, shown in
/// permission prompts. Empty when absent. Sentinel-terminated
/// so it can cross a C apprt boundary without copies.
name: [:0]const u8,
/// True when a stored session grant already covers this
/// request, so any permission prompt is skipped.
granted: bool,
/// The response terminator, matching the request's.
terminator: terminal.osc.Terminator,
pub fn destroy(self: *KittyRead) void {
// The struct itself lives in the arena, so move the arena
// out before tearing it down.
var arena = self.arena;
arena.deinit();
}
};
/// Make this a valid gobject if we're in a GTK environment.
pub const getGObjectType = switch (build_config.app_runtime) {
.gtk => @import("gobject").ext.defineBoxed(

View File

@@ -72,6 +72,11 @@ pub const Message = union(enum) {
/// Read the clipboard and write to the pty.
clipboard_read: apprt.Clipboard,
/// A Kitty clipboard protocol (OSC 5522) read request. The receiver
/// takes ownership of the request state and must eventually destroy
/// it.
kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead,
/// Write the clipboard contents.
clipboard_write: struct {
clipboard_type: apprt.Clipboard,

View File

@@ -717,6 +717,15 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void {
}
/// Sends a DSR response for the current color scheme to the pty.
/// Record a Kitty clipboard protocol session grant so future requests
/// carrying the password skip the permission prompt.
pub fn kittyClipboardGrant(self: *Termio, pw: []const u8) !void {
self.renderer_state.mutex.lockUncancelable(global.io());
defer self.renderer_state.mutex.unlock(global.io());
try self.terminal_stream.handler.kittyClipboardGrant(pw);
}
pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void {
self.renderer_state.mutex.lockUncancelable(global.io());
defer self.renderer_state.mutex.unlock(global.io());

View File

@@ -336,6 +336,10 @@ fn drainMailbox(
}
},
.jump_to_prompt => |v| try io.jumpToPrompt(v),
.kitty_clipboard_grant => |v| {
defer v.alloc.free(v.pw);
try io.kittyClipboardGrant(v.pw);
},
.start_synchronized_output => self.startSynchronizedOutput(cb),
.linefeed_mode => |v| self.flags.linefeed_mode = v,
.focused => |v| try io.focusGained(data, v),

View File

@@ -82,6 +82,14 @@ pub const Message = union(enum) {
/// The surface gained or lost focus.
focused: bool,
/// Record a Kitty clipboard protocol session grant for a password
/// so future requests carrying it skip the permission prompt. The
/// password is allocated and must be freed.
kitty_clipboard_grant: struct {
alloc: Allocator,
pw: []const u8,
},
/// Write where the data fits in the union.
write_small: WriteReq.Small,
@@ -111,6 +119,7 @@ pub const Message = union(enum) {
v.alloc.destroy(v.ptr);
},
.write_alloc => |v| v.alloc.free(v.data),
.kitty_clipboard_grant => |v| v.alloc.free(v.pw),
else => {},
}
}

View File

@@ -68,6 +68,10 @@ pub const StreamHandler = struct {
/// The tmux control mode viewer state.
tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {},
/// Session password grants for the Kitty clipboard protocol.
/// Requests carrying a granted password skip the permission prompt.
kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{},
/// This is set to true when a message was written to the termio
/// mailbox. This can be used by callers to determine if they need
/// to wake up the termio thread.
@@ -85,6 +89,7 @@ pub const StreamHandler = struct {
pub fn deinit(self: *StreamHandler) void {
self.apc.deinit();
self.dcs.deinit();
self.kitty_clipboard_grants.deinit(self.alloc);
if (comptime tmux_enabled) tmux: {
const viewer = self.tmux_viewer orelse break :tmux;
viewer.deinit();
@@ -352,11 +357,11 @@ pub const StreamHandler = struct {
.apc_end => try self.apcEnd(),
.apc_put => self.apc.feed(self.alloc, value),
.apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
.kitty_clipboard => try self.kittyClipboard(value),
// Unimplemented
.title_push,
.title_pop,
.kitty_clipboard,
.kitty_dnd,
=> {},
}
@@ -869,6 +874,10 @@ pub const StreamHandler = struct {
self.terminal.fullReset();
try self.setMouseShape(.text);
// Full reset clears Kitty clipboard session grants.
self.kitty_clipboard_grants.deinit(self.alloc);
self.kitty_clipboard_grants = .{};
// Reset resets our palette so we report it for mode 2031.
self.messageWriter(.{ .color_scheme_report = .{ .force = false } });
@@ -876,6 +885,12 @@ pub const StreamHandler = struct {
self.progressReport(.{ .state = .remove });
}
/// Record a Kitty clipboard protocol session grant so future
/// requests with this password skip the permission prompt.
pub fn kittyClipboardGrant(self: *StreamHandler, pw: []const u8) !void {
try self.kitty_clipboard_grants.grant(self.alloc, pw, .read, false);
}
pub fn queryKittyKeyboard(self: *StreamHandler) !void {
log.debug("querying kitty keyboard mode", .{});
var data: termio.Message.WriteReq.Small.Array = undefined;
@@ -990,6 +1005,135 @@ pub const StreamHandler = struct {
});
}
/// Handle one Kitty clipboard protocol (OSC 5522) packet.
fn kittyClipboard(
self: *StreamHandler,
v: terminal.osc.Command.KittyClipboardProtocol,
) !void {
const kitty_clipboard = terminal.kitty.clipboard;
// Decode and validate the metadata. Malformed metadata drops
// the packet without any response, matching kitty.
var arena: std.heap.ArenaAllocator = .init(self.alloc);
defer arena.deinit();
const meta = (try kitty_clipboard.Metadata.parse(
arena.allocator(),
v.metadata,
)) orelse return;
switch (meta.op) {
.read => try self.kittyClipboardRead(
&meta,
v.payload orelse "",
v.terminator,
),
// Writes aren't implemented in the GUI yet. Failing the
// transaction up front matches a libghostty-vt handler
// without a clipboard_write effect and spares the program
// from waiting on a commit response that never comes.
.write => {
var stream: std.Io.Writer.Allocating = .init(self.alloc);
defer stream.deinit();
try (kitty_clipboard.Response{
.op = .write,
.status = .ENOSYS,
.id = meta.id,
.terminator = v.terminator,
}).encode(&stream.writer);
self.messageWriter(.{ .write_alloc = .{
.alloc = self.alloc,
.data = try stream.toOwnedSlice(),
} });
},
// Data packets without an accepted write transaction are
// silently ignored, matching kitty.
.wdata, .walias => {},
}
}
fn kittyClipboardRead(
self: *StreamHandler,
meta: *const terminal.kitty.clipboard.Metadata,
payload: []const u8,
terminator: terminal.osc.Terminator,
) !void {
const kitty_clipboard = terminal.kitty.clipboard;
// Everything about the request, including the request struct
// itself, lives in a single arena that crosses to the surface
// thread, which owns it from the moment the message is sent.
var arena: std.heap.ArenaAllocator = .init(self.alloc);
errdefer arena.deinit();
const alloc = arena.allocator();
// The payload is the requested MIME list. A read request with
// an undecodable payload is dropped without any response,
// matching kitty.
const decoded = kitty_clipboard.Payload.init(
alloc,
payload,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Invalid => {
arena.deinit();
return;
},
};
// The targets type ('.') asks for the listing of available
// types rather than data. Requested types beyond the cap are
// dropped and simply never served, which is how the protocol
// reports an unavailable type anyway.
var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
var mimes_len: usize = 0;
var list = false;
var it = decoded.mimeIterator();
while (it.next()) |mime| {
if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
list = true;
continue;
}
if (mimes_len == mimes_buf.len) continue;
mimes_buf[mimes_len] = mime;
mimes_len += 1;
}
// Per the spec a password without a name is no password. A
// stored session grant for it lets the surface skip its
// permission prompt.
const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .read);
const req = try alloc.create(apprt.ClipboardRequest.KittyRead);
const mimes = try alloc.alloc([:0]const u8, mimes_len);
for (mimes_buf[0..mimes_len], mimes) |src, *dst| {
dst.* = try alloc.dupeZ(u8, src);
}
const id = try alloc.dupe(u8, meta.id);
const pw_owned = try alloc.dupe(u8, pw);
const name_owned = try alloc.dupeZ(u8, meta.name);
req.* = .{
// The arena must be copied in last so it tracks every
// allocation above.
.arena = arena,
.location = switch (meta.loc) {
.primary => .primary,
else => .standard,
},
.mimes = mimes,
.list = list,
.id = id,
.pw = pw_owned,
.name = name_owned,
.granted = granted,
.terminator = terminator,
};
self.surfaceMessageWriter(.{ .kitty_clipboard_read = req });
}
fn semanticPrompt(
self: *StreamHandler,
cmd: Stream.Action.SemanticPrompt,

View File

@@ -59,6 +59,7 @@ extend-ignore-re = [
Pn = "Pn"
thr = "thr"
# Swift oddities
Datas = "Datas"
Requestor = "Requestor"
iterm = "iterm"
ACCES = "ACCES"