mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 16:41:44 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
308
src/Surface.zig
308
src/Surface.zig
@@ -1061,6 +1061,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
||||
_ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
|
||||
},
|
||||
|
||||
.kitty_clipboard_read => |req| try self.kittyClipboardRead(req),
|
||||
|
||||
.clipboard_write => |w| switch (w.req) {
|
||||
.small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type),
|
||||
.stable => |v| try self.clipboardWrite(v, w.clipboard_type),
|
||||
@@ -5107,15 +5109,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
{},
|
||||
),
|
||||
|
||||
.paste_from_clipboard => return try self.startClipboardRequest(
|
||||
.paste_from_clipboard => return (try self.startClipboardRequest(
|
||||
.standard,
|
||||
.{ .paste = {} },
|
||||
),
|
||||
)) == .started,
|
||||
|
||||
.paste_from_selection => return try self.startClipboardRequest(
|
||||
.paste_from_selection => return (try self.startClipboardRequest(
|
||||
.selection,
|
||||
.{ .paste = {} },
|
||||
),
|
||||
)) == .started,
|
||||
|
||||
.increase_font_size => |delta| {
|
||||
// Max delta is somewhat arbitrary.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."));
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,7 @@ extend-ignore-re = [
|
||||
Pn = "Pn"
|
||||
thr = "thr"
|
||||
# Swift oddities
|
||||
Datas = "Datas"
|
||||
Requestor = "Requestor"
|
||||
iterm = "iterm"
|
||||
ACCES = "ACCES"
|
||||
|
||||
Reference in New Issue
Block a user