mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-13 04:30:53 +00:00
terminal: add clipboard_set effect for OSC 52 clipboard writes
libghostty-vt already parses OSC 52 into the clipboard_contents action but
the stream handler dropped it in the no-effect list, so embedders had no way
to observe a program's clipboard writes. Add a clipboard_set effect following
the existing bell/title_changed pattern and expose it through the C API as
GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET.
The callback receives the OSC 52 kind byte and the base64 payload exactly as
received; decoding and kind interpretation are left to the embedder, matching
how ghostty itself defers decoding to the apprt layer.
Clipboard read requests ("?") are never forwarded: answering one would let
any program running in the terminal silently read the user's clipboard, and
a VT state library cannot mediate that with user consent. Empty payloads are
also ignored rather than inventing clear semantics.
This commit is contained in:
@@ -82,6 +82,7 @@ extern "C" {
|
||||
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
|
||||
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET` | `GhosttyTerminalClipboardSetFn` | Clipboard write via OSC 52 |
|
||||
*
|
||||
* ### Defining a write_pty callback
|
||||
* @snippet c-vt-effects/src/main.c effects-write-pty
|
||||
@@ -284,6 +285,34 @@ typedef struct {
|
||||
typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal,
|
||||
void* userdata);
|
||||
|
||||
/**
|
||||
* Callback function type for clipboard_set.
|
||||
*
|
||||
* Called when the running program sets the clipboard via OSC 52. The
|
||||
* kind byte identifies the target selection ('c' clipboard, 'p' primary,
|
||||
* 's' selection, '0'-'7' cut buffers); most terminals treat everything
|
||||
* as the standard clipboard. The data is the base64-encoded payload
|
||||
* exactly as received; decoding is left to the embedder. The data is
|
||||
* only valid for the duration of the call.
|
||||
*
|
||||
* OSC 52 clipboard *read* requests ("?") are never forwarded to this
|
||||
* callback: answering one would let any program running in the terminal
|
||||
* silently read the user's clipboard.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
* @param kind The OSC 52 selection kind byte
|
||||
* @param data Pointer to the base64-encoded payload bytes
|
||||
* @param len Length of the payload in bytes
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef void (*GhosttyTerminalClipboardSetFn)(GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
uint8_t kind,
|
||||
const uint8_t* data,
|
||||
size_t len);
|
||||
|
||||
/**
|
||||
* Callback function type for color scheme queries (CSI ? 996 n).
|
||||
*
|
||||
@@ -693,6 +722,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input type: GhosttyTerminalPwdChangedFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25,
|
||||
|
||||
/**
|
||||
* Callback invoked when the running program sets the clipboard via
|
||||
* OSC 52. Set to NULL to ignore clipboard writes. Read requests are
|
||||
* always ignored; see GhosttyTerminalClipboardSetFn.
|
||||
*
|
||||
* Input type: GhosttyTerminalClipboardSetFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET = 26,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const Effects = struct {
|
||||
title_changed: ?TitleChangedFn = null,
|
||||
pwd_changed: ?PwdChangedFn = null,
|
||||
size_cb: ?SizeFn = null,
|
||||
clipboard_set: ?ClipboardSetFn = null,
|
||||
|
||||
/// Scratch buffer for DA1 feature codes. The device attributes
|
||||
/// trampoline converts C feature codes into this buffer and returns
|
||||
@@ -84,6 +85,11 @@ const Effects = struct {
|
||||
/// (len=0) causes the default "libghostty" to be reported.
|
||||
pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String;
|
||||
|
||||
/// C function pointer type for the clipboard_set callback. The kind
|
||||
/// byte identifies the OSC 52 target selection and the data is the
|
||||
/// base64-encoded payload exactly as received.
|
||||
pub const ClipboardSetFn = *const fn (Terminal, ?*anyopaque, u8, [*]const u8, usize) callconv(lib.calling_conv) void;
|
||||
|
||||
/// C function pointer type for the title_changed callback.
|
||||
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
|
||||
|
||||
@@ -138,6 +144,13 @@ const Effects = struct {
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata);
|
||||
}
|
||||
|
||||
fn clipboardSetTrampoline(handler: *Handler, kind: u8, data: []const u8) void {
|
||||
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
|
||||
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
|
||||
const func = wrapper.effects.clipboard_set orelse return;
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata, kind, data.ptr, data.len);
|
||||
}
|
||||
|
||||
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
|
||||
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
|
||||
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
|
||||
@@ -296,6 +309,7 @@ fn new_(
|
||||
.title_changed = &Effects.titleChangedTrampoline,
|
||||
.pwd_changed = &Effects.pwdChangedTrampoline,
|
||||
.size = &Effects.sizeTrampoline,
|
||||
.clipboard_set = &Effects.clipboardSetTrampoline,
|
||||
};
|
||||
|
||||
wrapper.* = .{
|
||||
@@ -343,6 +357,7 @@ pub const Option = enum(c_int) {
|
||||
default_cursor_blink = 23,
|
||||
glyph_protocol = 24,
|
||||
pwd_changed = 25,
|
||||
clipboard_set = 26,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -357,6 +372,7 @@ pub const Option = enum(c_int) {
|
||||
.title_changed => ?Effects.TitleChangedFn,
|
||||
.pwd_changed => ?Effects.PwdChangedFn,
|
||||
.size_cb => ?Effects.SizeFn,
|
||||
.clipboard_set => ?Effects.ClipboardSetFn,
|
||||
.title, .pwd => ?*const lib.String,
|
||||
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
|
||||
.color_palette => ?*const color.PaletteC,
|
||||
@@ -413,6 +429,7 @@ fn setTyped(
|
||||
.title_changed => wrapper.effects.title_changed = value,
|
||||
.pwd_changed => wrapper.effects.pwd_changed = value,
|
||||
.size_cb => wrapper.effects.size_cb = value,
|
||||
.clipboard_set => wrapper.effects.clipboard_set = value,
|
||||
.title => {
|
||||
const str = if (value) |v| v.ptr[0..v.len] else "";
|
||||
wrapper.terminal.setTitle(str) catch return .out_of_memory;
|
||||
@@ -2421,6 +2438,79 @@ test "set pwd_changed callback" {
|
||||
try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
|
||||
}
|
||||
|
||||
test "set clipboard_set callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
var last_userdata: ?*anyopaque = null;
|
||||
var last_kind: u8 = 0;
|
||||
var last_data: [64]u8 = undefined;
|
||||
var last_len: usize = 0;
|
||||
|
||||
fn clipboardSet(
|
||||
_: Terminal,
|
||||
ud: ?*anyopaque,
|
||||
kind: u8,
|
||||
data: [*]const u8,
|
||||
len: usize,
|
||||
) callconv(lib.calling_conv) void {
|
||||
count += 1;
|
||||
last_userdata = ud;
|
||||
last_kind = kind;
|
||||
last_len = @min(len, last_data.len);
|
||||
@memcpy(last_data[0..last_len], data[0..last_len]);
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
S.last_userdata = null;
|
||||
|
||||
var sentinel: u8 = 88;
|
||||
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_set, @ptrCast(&S.clipboardSet)));
|
||||
|
||||
// OSC 52 ; c ; base64("hello") ST — clipboard write
|
||||
const seq1 = "\x1B]52;c;aGVsbG8=\x1B\\";
|
||||
vt_write(t, seq1, seq1.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.count);
|
||||
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
|
||||
try testing.expectEqual(@as(u8, 'c'), S.last_kind);
|
||||
try testing.expectEqualStrings("aGVsbG8=", S.last_data[0..S.last_len]);
|
||||
|
||||
// OSC 52 read requests ("?") must never reach the callback.
|
||||
const seq2 = "\x1B]52;c;?\x1B\\";
|
||||
vt_write(t, seq2, seq2.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.count);
|
||||
}
|
||||
|
||||
test "clipboard_set without callback is silent" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
// OSC 52 without a callback should not crash
|
||||
const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
|
||||
vt_write(t, seq, seq.len);
|
||||
}
|
||||
|
||||
test "pwd_changed without callback is silent" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -88,6 +88,19 @@ pub const Handler = struct {
|
||||
/// handler.terminal.getPwd().
|
||||
pwd_changed: ?*const fn (*Handler) void,
|
||||
|
||||
/// Called when the running program sets the clipboard via OSC 52.
|
||||
/// The kind byte identifies the target selection ('c' clipboard,
|
||||
/// 'p' primary, 's' selection, '0'-'7' cut buffers) and data is the
|
||||
/// base64-encoded payload exactly as received; decoding and kind
|
||||
/// interpretation are left to the embedder. The data is only valid
|
||||
/// during the lifetime of the call.
|
||||
///
|
||||
/// Clipboard read requests (OSC 52 with a "?" payload) are never
|
||||
/// forwarded: answering one would let any program running in the
|
||||
/// terminal silently read the user's clipboard, and a VT state
|
||||
/// library has no way to mediate that with user consent.
|
||||
clipboard_set: ?*const fn (*Handler, u8, []const u8) void,
|
||||
|
||||
/// Called in response to an XTVERSION query. Returns the version
|
||||
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
|
||||
/// must be valid for the lifetime of the call. The maximum length
|
||||
@@ -99,6 +112,7 @@ pub const Handler = struct {
|
||||
/// effects beyond that.
|
||||
pub const readonly: Effects = .{
|
||||
.bell = null,
|
||||
.clipboard_set = null,
|
||||
.color_scheme = null,
|
||||
.device_attributes = null,
|
||||
.enquiry = null,
|
||||
@@ -276,6 +290,7 @@ pub const Handler = struct {
|
||||
.window_title => self.windowTitle(value.title),
|
||||
.report_pwd => self.reportPwd(value.url),
|
||||
.xtversion => self.reportXtversion(),
|
||||
.clipboard_contents => self.clipboardContents(value.kind, value.data),
|
||||
|
||||
// No supported DCS commands have any terminal-modifying effects,
|
||||
// but they may in the future. For now we just ignore it.
|
||||
@@ -287,7 +302,6 @@ pub const Handler = struct {
|
||||
// Have no terminal-modifying effect
|
||||
.show_desktop_notification,
|
||||
.progress_report,
|
||||
.clipboard_contents,
|
||||
.title_push,
|
||||
.title_pop,
|
||||
=> {},
|
||||
@@ -304,6 +318,19 @@ pub const Handler = struct {
|
||||
func(self);
|
||||
}
|
||||
|
||||
fn clipboardContents(self: *Handler, kind: u8, data: []const u8) void {
|
||||
const func = self.effects.clipboard_set orelse return;
|
||||
|
||||
// Read requests are deliberately not forwarded; see the
|
||||
// clipboard_set effect docs. Empty payloads carry nothing to set
|
||||
// (some emitters use them to clear the clipboard), so they are
|
||||
// ignored as well rather than inventing clear semantics here.
|
||||
if (data.len == 0) return;
|
||||
if (data.len == 1 and data[0] == '?') return;
|
||||
|
||||
func(self, kind, data);
|
||||
}
|
||||
|
||||
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
|
||||
const func = self.effects.device_attributes orelse return;
|
||||
const attrs = func(self);
|
||||
@@ -1417,6 +1444,67 @@ test "bell effect callback" {
|
||||
}
|
||||
}
|
||||
|
||||
test "clipboard_set effect callback" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
// Test OSC 52 with null callback (default readonly effects) doesn't crash
|
||||
{
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
|
||||
|
||||
// Terminal should still be functional after the ignored sequence
|
||||
s.nextSlice("AfterClipboard");
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("AfterClipboard", str);
|
||||
}
|
||||
|
||||
t.fullReset();
|
||||
|
||||
// Test OSC 52 with a callback
|
||||
{
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
var last_kind: u8 = 0;
|
||||
var last_data: ?[:0]const u8 = null;
|
||||
fn clipboardSet(_: *Handler, kind: u8, data: []const u8) void {
|
||||
count += 1;
|
||||
last_kind = kind;
|
||||
if (last_data) |old| testing.allocator.free(old);
|
||||
last_data = testing.allocator.dupeZ(u8, data) catch null;
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
defer if (S.last_data) |data| testing.allocator.free(data);
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.clipboard_set = &S.clipboardSet;
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
// A write is forwarded with its kind and undecoded base64 payload.
|
||||
s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 1), S.count);
|
||||
try testing.expectEqual(@as(u8, 'c'), S.last_kind);
|
||||
try testing.expectEqualStrings("aGVsbG8=", S.last_data.?);
|
||||
|
||||
// BEL termination and a non-default kind work too.
|
||||
s.nextSlice("\x1B]52;p;d29ybGQ=\x07");
|
||||
try testing.expectEqual(@as(usize, 2), S.count);
|
||||
try testing.expectEqual(@as(u8, 'p'), S.last_kind);
|
||||
try testing.expectEqualStrings("d29ybGQ=", S.last_data.?);
|
||||
|
||||
// Read requests and empty payloads are never forwarded.
|
||||
s.nextSlice("\x1B]52;c;?\x1B\\");
|
||||
s.nextSlice("\x1B]52;c;\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 2), S.count);
|
||||
}
|
||||
}
|
||||
|
||||
test "request mode DECRQM with write_pty callback" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user