mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
libghostty: add ghostty_terminal_paste C API with paste events example
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
# Example: `ghostty-vt` Paste Utilities
|
||||
# Example: `ghostty-vt` Paste
|
||||
|
||||
This contains a simple example of how to use the `ghostty-vt` paste
|
||||
utilities to check if paste data is safe and encode it for terminal input.
|
||||
This contains a simple example of how to paste into a `ghostty-vt`
|
||||
terminal with `ghostty_terminal_paste`: plain and bracketed (mode 2004)
|
||||
text pastes, the unsafe-paste confirmation flow, and Kitty clipboard
|
||||
protocol paste events (mode 5522) including the program's follow-up
|
||||
clipboard read. It also shows the terminal-free building blocks for
|
||||
checking paste safety and encoding paste data.
|
||||
|
||||
This uses a `build.zig` and `Zig` to build the C program so that we
|
||||
can reuse a lot of our build logic and depend directly on our source
|
||||
|
||||
@@ -1,7 +1,130 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
#define GS(s) ((GhosttyString){.ptr = (const uint8_t*)(s), .len = sizeof(s) - 1})
|
||||
|
||||
// Print bytes destined for the pty with control characters made visible.
|
||||
static void print_escaped(const uint8_t* data, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
switch (data[i]) {
|
||||
case 0x1b: printf("ESC"); break;
|
||||
case '\r': printf("\\r"); break;
|
||||
case '\n': printf("\\n"); break;
|
||||
default: putchar(data[i]); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The base64 password of the last paste event, captured from the OK
|
||||
// packet so the example can play the program's side of the protocol.
|
||||
static char event_pw[128];
|
||||
|
||||
// Everything the terminal writes to the running program: the pasted
|
||||
// text, or the paste event packets when mode 5522 is enabled.
|
||||
static void on_write_pty(GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const uint8_t* data,
|
||||
size_t len) {
|
||||
(void)terminal;
|
||||
(void)userdata;
|
||||
printf(" -> pty (%zu bytes): ", len);
|
||||
print_escaped(data, len);
|
||||
printf("\n");
|
||||
|
||||
// A paste event's OK packet: OSC 5522 ; type=read:status=OK:pw=<b64> ST
|
||||
const char* prefix = "\x1b]5522;type=read:status=OK:pw=";
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (len > prefix_len && memcmp(data, prefix, prefix_len) == 0) {
|
||||
size_t end = prefix_len;
|
||||
while (end < len && data[end] != 0x1b) end++;
|
||||
size_t pw_len = end - prefix_len;
|
||||
if (pw_len < sizeof(event_pw)) {
|
||||
memcpy(event_pw, data + prefix_len, pw_len);
|
||||
event_pw[pw_len] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serves clipboard reads. After a paste event the program's read arrives
|
||||
// with `granted` set, because it carries the event's one-time password,
|
||||
// so the embedder skips its permission prompt.
|
||||
static void on_clipboard_read(GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const GhosttyClipboardRead* read) {
|
||||
(void)terminal;
|
||||
(void)userdata;
|
||||
printf(" clipboard read: name=\"");
|
||||
fwrite(read->name.ptr, 1, read->name.len, stdout);
|
||||
printf("\" granted=%s\n", read->granted ? "yes (no prompt needed)" : "no");
|
||||
|
||||
const char* text = "hello from the clipboard";
|
||||
GhosttyClipboardContent content = {
|
||||
.mime = GS("text/plain"),
|
||||
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
|
||||
};
|
||||
GhosttyClipboardReadReply reply = {
|
||||
.size = sizeof(reply),
|
||||
.result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS,
|
||||
.contents = &content,
|
||||
.contents_len = 1,
|
||||
.available = NULL,
|
||||
.available_len = 0,
|
||||
.remember = false,
|
||||
};
|
||||
read->reply(read, &reply);
|
||||
}
|
||||
|
||||
// A real embedder would show a dialog here.
|
||||
static bool confirm_with_user(void) {
|
||||
printf(" paste could inject commands; user confirmed\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
//! [terminal-paste]
|
||||
// Paste whatever the clipboard holds. The terminal applies its own
|
||||
// state: bracketed paste framing (mode 2004) or a Kitty paste event
|
||||
// (mode 5522) instead of the text.
|
||||
static void paste_clipboard(GhosttyTerminal terminal, const char* text) {
|
||||
GhosttyClipboardContent contents[] = {
|
||||
// The first text representation is what a text paste writes.
|
||||
{.mime = GS("text/plain"),
|
||||
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)}},
|
||||
// Listed on a paste event, never written, so no data is needed.
|
||||
{.mime = GS("image/png"), .data = {.ptr = NULL, .len = 0}},
|
||||
};
|
||||
GhosttyPaste paste = {
|
||||
.size = sizeof(paste),
|
||||
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
|
||||
.source = GHOSTTY_PASTE_SOURCE_CLIPBOARD,
|
||||
.contents = contents,
|
||||
.contents_len = sizeof(contents) / sizeof(contents[0]),
|
||||
.allow_unsafe = false,
|
||||
};
|
||||
|
||||
bool written = false;
|
||||
GhosttyResult result = ghostty_terminal_paste(terminal, &paste, &written);
|
||||
if (result == GHOSTTY_REJECTED) {
|
||||
// The text could inject commands (e.g. a newline outside of a
|
||||
// bracketed paste). Nothing was written; ask, then retry.
|
||||
if (!confirm_with_user()) return;
|
||||
paste.allow_unsafe = true;
|
||||
result = ghostty_terminal_paste(terminal, &paste, &written);
|
||||
}
|
||||
if (result != GHOSTTY_SUCCESS) {
|
||||
fprintf(stderr, "paste failed: %d\n", (int)result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Whether the pty got the text or a paste event depends on the
|
||||
// terminal's modes; either way it went through write_pty above.
|
||||
printf(" %s\n", written ? "written" : "nothing to paste");
|
||||
}
|
||||
//! [terminal-paste]
|
||||
|
||||
//! [paste-safety]
|
||||
void safety_example() {
|
||||
const char* safe_data = "hello world";
|
||||
@@ -29,27 +152,89 @@ void encode_example() {
|
||||
|
||||
if (result == GHOSTTY_SUCCESS) {
|
||||
printf("Encoded %zu bytes: ", written);
|
||||
fwrite(buf, 1, written, stdout);
|
||||
print_escaped((const uint8_t*)buf, written);
|
||||
printf("\n");
|
||||
}
|
||||
}
|
||||
//! [paste-encode]
|
||||
|
||||
static void vt_write(GhosttyTerminal terminal, const char* seq) {
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t*)seq, strlen(seq));
|
||||
}
|
||||
|
||||
int main() {
|
||||
GhosttyTerminal terminal = NULL;
|
||||
if (ghostty_terminal_new(NULL, &terminal, 80, 24) != GHOSTTY_SUCCESS) {
|
||||
fprintf(stderr, "Failed to create terminal\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pasted bytes and paste events go to write_pty. Serving clipboard
|
||||
// reads is what lets the terminal send paste events at all: without
|
||||
// this callback the program could never read the clipboard, so pastes
|
||||
// stay text even when mode 5522 is enabled.
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY,
|
||||
(const void*)on_write_pty);
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ,
|
||||
(const void*)on_clipboard_read);
|
||||
|
||||
printf("Plain paste:\n");
|
||||
paste_clipboard(terminal, "hello world");
|
||||
|
||||
printf("Paste with a newline (refused, then confirmed):\n");
|
||||
paste_clipboard(terminal, "echo hi\n");
|
||||
|
||||
// The program enables bracketed paste: newlines are safe inside the
|
||||
// frame and are preserved.
|
||||
printf("Bracketed paste (mode 2004):\n");
|
||||
vt_write(terminal, "\x1b[?2004h");
|
||||
paste_clipboard(terminal, "line one\nline two");
|
||||
|
||||
// The program enables paste events: the clipboard's MIME types are
|
||||
// listed with a one-time password instead of writing the data.
|
||||
printf("Paste event (mode 5522):\n");
|
||||
vt_write(terminal, "\x1b[?5522h");
|
||||
paste_clipboard(terminal, "hello world");
|
||||
|
||||
// Play the program's side: read the clipboard with the password from
|
||||
// the event. The read arrives granted and the data is served through
|
||||
// write_pty as base64 without any permission prompt.
|
||||
if (event_pw[0] != 0) {
|
||||
printf("Program reads with the event password:\n");
|
||||
char read_seq[256];
|
||||
snprintf(read_seq, sizeof(read_seq),
|
||||
"\x1b]5522;type=read:pw=%s:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
|
||||
event_pw);
|
||||
vt_write(terminal, read_seq);
|
||||
}
|
||||
|
||||
// Text inserted by other means (IME, drag and drop) is never an event.
|
||||
printf("IME text with mode 5522 enabled:\n");
|
||||
{
|
||||
const char* text = "committed";
|
||||
GhosttyClipboardContent content = {
|
||||
.mime = GS("text/plain"),
|
||||
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
|
||||
};
|
||||
GhosttyPaste paste = {
|
||||
.size = sizeof(paste),
|
||||
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
|
||||
.source = GHOSTTY_PASTE_SOURCE_TEXT,
|
||||
.contents = &content,
|
||||
.contents_len = 1,
|
||||
.allow_unsafe = true,
|
||||
};
|
||||
bool written = false;
|
||||
if (ghostty_terminal_paste(terminal, &paste, &written) == GHOSTTY_SUCCESS &&
|
||||
written) {
|
||||
printf(" written\n");
|
||||
}
|
||||
}
|
||||
|
||||
ghostty_terminal_free(terminal);
|
||||
|
||||
printf("\nTerminal-free building blocks:\n");
|
||||
safety_example();
|
||||
|
||||
// Test unsafe paste data with bracketed paste end sequence
|
||||
const char *unsafe_escape = "evil\x1b[201~code";
|
||||
if (!ghostty_paste_is_safe(unsafe_escape, strlen(unsafe_escape))) {
|
||||
printf("Data with escape sequence is UNSAFE\n");
|
||||
}
|
||||
|
||||
// Test empty data
|
||||
const char *empty_data = "";
|
||||
if (ghostty_paste_is_safe(empty_data, 0)) {
|
||||
printf("Empty data is safe\n");
|
||||
}
|
||||
|
||||
encode_example();
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
* - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state
|
||||
* - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
|
||||
* - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
|
||||
* - @ref paste "Paste Utilities" - Validate paste data safety
|
||||
* - @ref paste "Paste" - Paste into a terminal, validate and encode paste data
|
||||
* - @ref unicode "Unicode Utilities" - Codepoint properties for text layout
|
||||
* - @ref build_info "Build Info" - Query compile-time build configuration
|
||||
* - @ref allocator "Memory Management" - Memory management and custom allocators
|
||||
@@ -53,7 +53,7 @@
|
||||
* - @ref c-vt/src/main.c - OSC parser example
|
||||
* - @ref c-vt-encode-key/src/main.c - Key encoding example
|
||||
* - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example
|
||||
* - @ref c-vt-paste/src/main.c - Paste safety check example
|
||||
* - @ref c-vt-paste/src/main.c - Paste example
|
||||
* - @ref c-vt-sgr/src/main.c - SGR parser example
|
||||
* - @ref c-vt-formatter/src/main.c - Terminal formatter example
|
||||
* - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
|
||||
@@ -83,8 +83,10 @@
|
||||
*/
|
||||
|
||||
/** @example c-vt-paste/src/main.c
|
||||
* This example demonstrates how to use the paste utilities to check if
|
||||
* paste data is safe before sending it to the terminal.
|
||||
* This example demonstrates how to paste into a terminal, including the
|
||||
* unsafe-paste confirmation flow and Kitty clipboard protocol paste events
|
||||
* (mode 5522), as well as the terminal-free paste safety and encoding
|
||||
* utilities.
|
||||
*/
|
||||
|
||||
/** @example c-vt-sgr/src/main.c
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
/**
|
||||
* @file paste.h
|
||||
*
|
||||
* Paste utilities - validate and encode paste data for terminal input.
|
||||
* Paste - paste into a terminal, and validate and encode paste data.
|
||||
*/
|
||||
|
||||
#ifndef GHOSTTY_VT_PASTE_H
|
||||
#define GHOSTTY_VT_PASTE_H
|
||||
|
||||
/** @defgroup paste Paste Utilities
|
||||
/** @defgroup paste Paste
|
||||
*
|
||||
* Utilities for validating and encoding paste data for terminal input.
|
||||
* Pasting into a terminal, plus the terminal-free utilities for
|
||||
* validating and encoding paste data.
|
||||
*
|
||||
* ## Basic Usage
|
||||
* ## Pasting into a Terminal
|
||||
*
|
||||
* Use ghostty_paste_is_safe() to check if paste data contains potentially
|
||||
* dangerous sequences before sending it to the terminal.
|
||||
* What a paste writes to the pty depends on the terminal's state, so
|
||||
* the recommended way to paste is ghostty_terminal_paste(). The embedder
|
||||
* hands over what the clipboard holds as MIME-typed contents (just
|
||||
* `text/plain` for an ordinary paste) and where it came from, and the
|
||||
* terminal decides how its current modes apply:
|
||||
*
|
||||
* Use ghostty_paste_encode() to encode paste data for writing to the pty,
|
||||
* - If Kitty clipboard protocol paste events (mode 5522,
|
||||
* GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated
|
||||
* (GHOSTTY_PASTE_SOURCE_CLIPBOARD), and a clipboard_read callback is
|
||||
* installed, the terminal sends the program a paste event listing the
|
||||
* clipboard's MIME types with a one-time password instead of the data.
|
||||
* The program then reads what it wants through the clipboard_read
|
||||
* callback, which arrives with `granted` set so no permission prompt
|
||||
* is needed.
|
||||
* - Otherwise the first text representation is written: unsafe control
|
||||
* bytes are replaced with spaces, and it is wrapped in bracketed paste
|
||||
* sequences if mode 2004 (GHOSTTY_MODE_BRACKETED_PASTE) is enabled, or
|
||||
* has its newlines converted to carriage returns if not.
|
||||
*
|
||||
* Text that could inject commands (a newline when unbracketed, or the
|
||||
* bracketed paste terminator when bracketed) is refused with
|
||||
* GHOSTTY_REJECTED unless GhosttyPaste::allow_unsafe is set. The usual
|
||||
* flow is to call once, confirm with the user on GHOSTTY_REJECTED, and
|
||||
* call again with `allow_unsafe` set.
|
||||
*
|
||||
* Output is delivered through the write_pty callback
|
||||
* (GHOSTTY_TERMINAL_OPT_WRITE_PTY) in a single call.
|
||||
*
|
||||
* @snippet c-vt-paste/src/main.c terminal-paste
|
||||
*
|
||||
* ## Building Blocks
|
||||
*
|
||||
* For embedders that encode without a terminal, ghostty_paste_is_safe()
|
||||
* checks if paste data contains potentially dangerous sequences
|
||||
* (conservatively, regardless of terminal state) and
|
||||
* ghostty_paste_encode() encodes paste data for writing to the pty,
|
||||
* including bracketed paste wrapping and unsafe byte stripping.
|
||||
*
|
||||
* ## Examples
|
||||
*
|
||||
* ### Safety Check
|
||||
*
|
||||
* @snippet c-vt-paste/src/main.c paste-safety
|
||||
@@ -35,11 +66,99 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <ghostty/vt/types.h>
|
||||
#include <ghostty/vt/terminal.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Why a paste happened.
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** The user pasted from a clipboard: keybind, menu, middle click. */
|
||||
GHOSTTY_PASTE_SOURCE_CLIPBOARD = 0,
|
||||
|
||||
/**
|
||||
* Text inserted some other way: IME commit, drag and drop, scripted
|
||||
* input. Always written as text, never as a paste event, matching
|
||||
* kitty. This is not a way to opt out of paste events; an embedder
|
||||
* that doesn't want them doesn't install a clipboard_read callback.
|
||||
*/
|
||||
GHOSTTY_PASTE_SOURCE_TEXT = 1,
|
||||
GHOSTTY_PASTE_SOURCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyPasteSource;
|
||||
|
||||
/**
|
||||
* A paste of clipboard contents into the terminal.
|
||||
*
|
||||
* This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The
|
||||
* contents array and the strings it points to are borrowed only for the
|
||||
* duration of the ghostty_terminal_paste() call.
|
||||
*/
|
||||
typedef struct {
|
||||
/** Size of this struct in bytes. */
|
||||
size_t size;
|
||||
|
||||
/**
|
||||
* The clipboard the contents came from. Reported to the program on a
|
||||
* paste event (the selection and primary locations are both reported
|
||||
* as the primary selection, the protocol knows only two); no effect
|
||||
* on a text paste.
|
||||
*/
|
||||
GhosttyClipboardLocation location;
|
||||
|
||||
/** Why this paste happened. */
|
||||
GhosttyPasteSource source;
|
||||
|
||||
/**
|
||||
* Borrowed array of the representations available, in preferred
|
||||
* order. A text paste writes the first entry with a text MIME type
|
||||
* such as "text/plain" and ignores the rest. A paste event lists every
|
||||
* entry's MIME type and never reads data, so non-text entries may have
|
||||
* empty data. May be NULL when contents_len is zero.
|
||||
*/
|
||||
const GhosttyClipboardContent* contents;
|
||||
|
||||
/** Number of entries in contents. */
|
||||
size_t contents_len;
|
||||
|
||||
/**
|
||||
* Write text that could inject commands. Call with false, confirm
|
||||
* with the user on GHOSTTY_REJECTED, and call again with true.
|
||||
*/
|
||||
bool allow_unsafe;
|
||||
} GhosttyPaste;
|
||||
|
||||
/**
|
||||
* Paste into the terminal according to its current state: a Kitty
|
||||
* clipboard protocol paste event if mode 5522 is enabled and a
|
||||
* clipboard_read callback is installed, otherwise the text framed per
|
||||
* mode 2004. See the group documentation for the full behavior. Output
|
||||
* goes through the write_pty callback in a single call. The viewport is
|
||||
* not scrolled; that is up to the embedder, as for key input.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param paste The paste request, borrowed for the duration of the call
|
||||
* @param[out] out_written On success, whether anything was written to
|
||||
* the pty (the encoded text or a paste event). False means
|
||||
* there was nothing to paste: no non-empty text
|
||||
* representation. May be NULL.
|
||||
* @return GHOSTTY_SUCCESS on success (see @p out_written);
|
||||
* GHOSTTY_REJECTED if the text could inject commands and
|
||||
* GhosttyPaste::allow_unsafe is false (nothing was written);
|
||||
* GHOSTTY_INVALID_VALUE for a NULL terminal or paste, or when no
|
||||
* write_pty callback is installed; GHOSTTY_OUT_OF_MEMORY;
|
||||
* GHOSTTY_IO_ERROR if there is no secure entropy source to mint
|
||||
* a paste event password (wasm32-freestanding without
|
||||
* GHOSTTY_SYS_OPT_RANDOM_SECURE set), in which case nothing was
|
||||
* written and no grant was recorded.
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_paste(
|
||||
GhosttyTerminal terminal,
|
||||
const GhosttyPaste* paste,
|
||||
bool* out_written);
|
||||
|
||||
/**
|
||||
* Check if paste data is safe to paste into the terminal.
|
||||
*
|
||||
@@ -49,7 +168,9 @@ extern "C" {
|
||||
* to exit bracketed paste mode and inject commands
|
||||
*
|
||||
* This check is conservative and considers data unsafe regardless of
|
||||
* current terminal state.
|
||||
* current terminal state. ghostty_terminal_paste() applies the
|
||||
* terminal-state-aware rule itself (newlines are safe inside a
|
||||
* bracketed paste); use this to apply the stricter rule on top.
|
||||
*
|
||||
* @param data The paste data to check (must not be NULL)
|
||||
* @param len The length of the data in bytes
|
||||
@@ -74,6 +195,9 @@ GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len);
|
||||
* GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written.
|
||||
* The caller can then retry with a sufficiently sized buffer.
|
||||
*
|
||||
* This is the encoder ghostty_terminal_paste() uses for a text paste;
|
||||
* use it directly when there is no terminal to paste into.
|
||||
*
|
||||
* @param data The paste data to encode (modified in place, may be NULL)
|
||||
* @param data_len The length of the input data in bytes
|
||||
* @param bracketed Whether bracketed paste mode is active
|
||||
|
||||
@@ -724,6 +724,11 @@ struct GhosttyClipboardRead {
|
||||
* serves a request for only the targets listing (`list` with no `mimes`)
|
||||
* without prompting.
|
||||
*
|
||||
* Installing this callback also enables Kitty paste events (mode 5522):
|
||||
* ghostty_terminal_paste() sends the program an event instead of the text,
|
||||
* and the program's follow-up read arrives here with `granted` set since
|
||||
* the user already pasted. See ghostty_terminal_paste().
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
* @param read Borrowed clipboard read request
|
||||
|
||||
@@ -100,6 +100,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
GHOSTTY_IO_ERROR = -5,
|
||||
/** Operation failed because encoded input exceeded a configured limit */
|
||||
GHOSTTY_LIMIT_EXCEEDED = -6,
|
||||
/**
|
||||
* Operation was rejected by a safety check (e.g. pasted text that could
|
||||
* inject commands). Nothing was done. Confirm with the user and retry
|
||||
* with the operation's allow flag set.
|
||||
*/
|
||||
GHOSTTY_REJECTED = -7,
|
||||
GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyResult;
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ comptime {
|
||||
@export(&c.focus_encode, .{ .name = "ghostty_focus_encode" });
|
||||
@export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" });
|
||||
@export(&c.paste_encode, .{ .name = "ghostty_paste_encode" });
|
||||
@export(&c.terminal_paste, .{ .name = "ghostty_terminal_paste" });
|
||||
@export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" });
|
||||
@export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" });
|
||||
@export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" });
|
||||
|
||||
@@ -160,6 +160,7 @@ pub const mouse_encoder_encode = mouse_encode.encode;
|
||||
|
||||
pub const paste_is_safe = paste.is_safe;
|
||||
pub const paste_encode = paste.encode;
|
||||
pub const terminal_paste = paste.terminal_paste;
|
||||
|
||||
pub const alloc_alloc = allocator.alloc;
|
||||
pub const alloc_free = allocator.free;
|
||||
|
||||
@@ -1,8 +1,84 @@
|
||||
const std = @import("std");
|
||||
const lib = @import("../lib.zig");
|
||||
const paste = @import("../../input/paste.zig");
|
||||
const terminal_paste_pkg = @import("../paste.zig");
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const terminal_c = @import("terminal.zig");
|
||||
const Terminal = terminal_c.Terminal;
|
||||
const ClipboardContent = terminal_c.ClipboardContent;
|
||||
const ClipboardRead = terminal_c.ClipboardRead;
|
||||
const ClipboardReadReply = terminal_c.ClipboardReadReply;
|
||||
const Result = @import("result.zig").Result;
|
||||
|
||||
/// Why a paste happened.
|
||||
///
|
||||
/// C: GhosttyPasteSource
|
||||
pub const Source = terminal_paste_pkg.Source;
|
||||
|
||||
/// A paste of clipboard contents into the terminal. Sized struct.
|
||||
///
|
||||
/// C: GhosttyPaste
|
||||
pub const Request = extern struct {
|
||||
size: usize = @sizeOf(Request),
|
||||
location: clipboard.Location,
|
||||
source: Source,
|
||||
contents: ?[*]const ClipboardContent,
|
||||
contents_len: usize,
|
||||
allow_unsafe: bool,
|
||||
};
|
||||
|
||||
pub fn terminal_paste(
|
||||
terminal_: Terminal,
|
||||
req_: ?*const Request,
|
||||
out_written: ?*bool,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const wrapper = terminal_ orelse return .invalid_value;
|
||||
const req = req_ orelse return .invalid_value;
|
||||
|
||||
// Every field is required; a smaller size is a caller from a
|
||||
// different ABI version than any this struct has had.
|
||||
if (req.size < @sizeOf(Request)) return .invalid_value;
|
||||
|
||||
// The handler always has a write_pty trampoline that no-ops without
|
||||
// a C callback, so the "nothing can be written" check is ours.
|
||||
if (wrapper.effects.write_pty == null) return .invalid_value;
|
||||
|
||||
const c_contents: []const ClipboardContent = if (req.contents) |ptr|
|
||||
ptr[0..req.contents_len]
|
||||
else
|
||||
&.{};
|
||||
|
||||
// A paste carries a handful of representations, so keep the common
|
||||
// case allocation-free.
|
||||
var sfa = std.heap.stackFallback(256, wrapper.terminal.gpa());
|
||||
const alloc = sfa.get();
|
||||
const contents = alloc.alloc(
|
||||
clipboard.Content,
|
||||
c_contents.len,
|
||||
) catch return .out_of_memory;
|
||||
defer alloc.free(contents);
|
||||
for (contents, c_contents) |*content, c_content| {
|
||||
content.* = .{
|
||||
.mime = c_content.mime.ptr[0..c_content.mime.len],
|
||||
.data = c_content.data.ptr[0..c_content.data.len],
|
||||
};
|
||||
}
|
||||
|
||||
const written = wrapper.stream.handler.paste(.{
|
||||
.location = req.location,
|
||||
.source = req.source,
|
||||
.contents = contents,
|
||||
.allow_unsafe = req.allow_unsafe,
|
||||
}) catch |err| return switch (err) {
|
||||
error.UnsafePaste => .rejected,
|
||||
error.NoWritePty => .invalid_value,
|
||||
error.OutOfMemory => .out_of_memory,
|
||||
error.EntropyUnavailable, error.Canceled => .io_error,
|
||||
};
|
||||
if (out_written) |ptr| ptr.* = written;
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool {
|
||||
const slice: []const u8 = if (data) |v| v[0..len] else &.{};
|
||||
return paste.isSafe(slice);
|
||||
@@ -127,3 +203,225 @@ test "is_safe with null empty data" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(is_safe(null, 0));
|
||||
}
|
||||
|
||||
/// Capture state for the terminal_paste tests: every pty write and the
|
||||
/// clipboard reads that follow a paste event.
|
||||
const TerminalPasteCapture = struct {
|
||||
var written: [1024]u8 = undefined;
|
||||
var written_len: usize = 0;
|
||||
var write_count: usize = 0;
|
||||
var read_count: usize = 0;
|
||||
var last_read_granted: bool = false;
|
||||
|
||||
fn reset() void {
|
||||
written_len = 0;
|
||||
write_count = 0;
|
||||
read_count = 0;
|
||||
last_read_granted = false;
|
||||
}
|
||||
|
||||
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
|
||||
@memcpy(written[written_len..][0..len], ptr[0..len]);
|
||||
written_len += len;
|
||||
write_count += 1;
|
||||
}
|
||||
|
||||
fn clipboardRead(_: Terminal, _: ?*anyopaque, request: *const ClipboardRead) callconv(lib.calling_conv) void {
|
||||
read_count += 1;
|
||||
last_read_granted = request.granted;
|
||||
const contents = [_]ClipboardContent{.{
|
||||
.mime = .init(@as([]const u8, "text/plain")),
|
||||
.data = .init(@as([]const u8, "Ghostty")),
|
||||
}};
|
||||
request.reply(request, &.{
|
||||
.size = @sizeOf(ClipboardReadReply),
|
||||
.result = .success,
|
||||
.contents = &contents,
|
||||
.contents_len = contents.len,
|
||||
.available = null,
|
||||
.available_len = 0,
|
||||
.remember = false,
|
||||
});
|
||||
}
|
||||
|
||||
fn writtenSlice() []const u8 {
|
||||
return written[0..written_len];
|
||||
}
|
||||
|
||||
/// A single text/plain request; the caller provides the content
|
||||
/// storage since the request borrows it.
|
||||
fn textRequest(content: *ClipboardContent, text: []const u8) Request {
|
||||
content.* = .{
|
||||
.mime = .init(@as([]const u8, "text/plain")),
|
||||
.data = .init(text),
|
||||
};
|
||||
return .{
|
||||
.location = .standard,
|
||||
.source = .clipboard,
|
||||
.contents = content[0..1],
|
||||
.contents_len = 1,
|
||||
.allow_unsafe = false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
test "terminal_paste null handling" {
|
||||
const testing = std.testing;
|
||||
const S = TerminalPasteCapture;
|
||||
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
var written: bool = true;
|
||||
var content: ClipboardContent = undefined;
|
||||
const req: Request = S.textRequest(&content, "hello");
|
||||
try testing.expectEqual(Result.invalid_value, terminal_paste(null, &req, &written));
|
||||
try testing.expectEqual(Result.invalid_value, terminal_paste(t, null, &written));
|
||||
try testing.expect(written);
|
||||
|
||||
// A size smaller than the struct is rejected.
|
||||
var small = req;
|
||||
small.size = @sizeOf(usize);
|
||||
try testing.expectEqual(Result.invalid_value, terminal_paste(t, &small, &written));
|
||||
}
|
||||
|
||||
test "terminal_paste without write_pty is invalid" {
|
||||
const testing = std.testing;
|
||||
const S = TerminalPasteCapture;
|
||||
S.reset();
|
||||
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
var content: ClipboardContent = undefined;
|
||||
const req: Request = S.textRequest(&content, "hello");
|
||||
try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null));
|
||||
}
|
||||
|
||||
test "terminal_paste text and unsafe" {
|
||||
const testing = std.testing;
|
||||
const S = TerminalPasteCapture;
|
||||
S.reset();
|
||||
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
|
||||
defer terminal_c.free(t);
|
||||
try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
|
||||
// Plain text, NULL out_written pointer is fine.
|
||||
var content: ClipboardContent = undefined;
|
||||
const req: Request = S.textRequest(&content, "hel\x1blo");
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &req, null));
|
||||
try testing.expectEqualStrings("hel lo", S.writtenSlice());
|
||||
try testing.expectEqual(@as(usize, 1), S.write_count);
|
||||
|
||||
// Unsafe is refused with nothing written, then allowed.
|
||||
S.reset();
|
||||
var written: bool = false;
|
||||
var unsafe_content: ClipboardContent = undefined;
|
||||
var unsafe: Request = S.textRequest(&unsafe_content, "rm -rf /\n");
|
||||
try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
try testing.expect(!written);
|
||||
|
||||
unsafe.allow_unsafe = true;
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &unsafe, &written));
|
||||
try testing.expect(written);
|
||||
try testing.expectEqualStrings("rm -rf /\r", S.writtenSlice());
|
||||
|
||||
// Bracketed paste mode frames the text through the real mode path.
|
||||
S.reset();
|
||||
const decset = "\x1b[?2004h";
|
||||
terminal_c.vt_write(t, decset, decset.len);
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &req, &written));
|
||||
try testing.expect(written);
|
||||
try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice());
|
||||
|
||||
// No text representation writes nothing. NULL contents with a zero
|
||||
// length is an empty list.
|
||||
S.reset();
|
||||
var empty_content: ClipboardContent = undefined;
|
||||
var empty: Request = S.textRequest(&empty_content, "");
|
||||
empty.contents = null;
|
||||
empty.contents_len = 0;
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &empty, &written));
|
||||
try testing.expect(!written);
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
}
|
||||
|
||||
test "terminal_paste event" {
|
||||
const testing = std.testing;
|
||||
const S = TerminalPasteCapture;
|
||||
S.reset();
|
||||
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
|
||||
defer terminal_c.free(t);
|
||||
try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
t.?.terminal.modes.set(.kitty_paste_events, true);
|
||||
|
||||
// Without a clipboard_read callback the paste stays text.
|
||||
var written: bool = false;
|
||||
const contents = [_]ClipboardContent{
|
||||
.{
|
||||
.mime = .init(@as([]const u8, "text/plain")),
|
||||
.data = .init(@as([]const u8, "secret")),
|
||||
},
|
||||
.{
|
||||
.mime = .init(@as([]const u8, "image/png")),
|
||||
.data = .init(@as([]const u8, "")),
|
||||
},
|
||||
};
|
||||
const req: Request = .{
|
||||
.location = .primary,
|
||||
.source = .clipboard,
|
||||
.contents = &contents,
|
||||
.contents_len = contents.len,
|
||||
.allow_unsafe = false,
|
||||
};
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &req, &written));
|
||||
try testing.expect(written);
|
||||
try testing.expectEqualStrings("secret", S.writtenSlice());
|
||||
|
||||
// With one, an event is sent listing every MIME type and the data
|
||||
// is never written.
|
||||
S.reset();
|
||||
try testing.expectEqual(Result.success, terminal_c.set(t, .clipboard_read, @ptrCast(&S.clipboardRead)));
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &req, &written));
|
||||
try testing.expect(written);
|
||||
try testing.expectEqual(@as(usize, 1), S.write_count);
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.writtenSlice(), "\x1b]5522;"));
|
||||
try testing.expect(std.mem.startsWith(u8, S.writtenSlice(), "\x1b]5522;type=read:status=OK:loc=primary:pw="));
|
||||
try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), "secret") == null);
|
||||
try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null);
|
||||
|
||||
// The program's read with the event password is granted once.
|
||||
const ok_prefix = "\x1b]5522;type=read:status=OK:loc=primary:pw=";
|
||||
const pw_end = std.mem.indexOfPos(u8, S.writtenSlice(), ok_prefix.len, "\x1b\\").?;
|
||||
var read_buf: [256]u8 = undefined;
|
||||
const read = try std.fmt.bufPrint(
|
||||
&read_buf,
|
||||
"\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
|
||||
.{S.writtenSlice()[ok_prefix.len..pw_end]},
|
||||
);
|
||||
S.reset();
|
||||
terminal_c.vt_write(t, read.ptr, read.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.read_count);
|
||||
try testing.expect(S.last_read_granted);
|
||||
try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";R2hvc3R0eQ==\x1b\\") != null);
|
||||
|
||||
S.reset();
|
||||
terminal_c.vt_write(t, read.ptr, read.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.read_count);
|
||||
try testing.expect(!S.last_read_granted);
|
||||
|
||||
// Text sources never become events.
|
||||
S.reset();
|
||||
var ime = req;
|
||||
ime.source = .text;
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &ime, &written));
|
||||
try testing.expect(written);
|
||||
try testing.expectEqualStrings("secret", S.writtenSlice());
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ pub const Result = enum(c_int) {
|
||||
no_value = -4,
|
||||
io_error = -5,
|
||||
limit_exceeded = -6,
|
||||
rejected = -7,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ const formatter_pkg = @import("../formatter.zig");
|
||||
const modes_pkg = @import("../modes.zig");
|
||||
const mouse_pkg = @import("../mouse.zig");
|
||||
const page = @import("../page.zig");
|
||||
const paste_pkg = @import("../paste.zig");
|
||||
const point = @import("../point.zig");
|
||||
const Selection = @import("../Selection.zig");
|
||||
const sgr = @import("../sgr.zig");
|
||||
@@ -37,6 +38,7 @@ const kitty_graphics = @import("kitty_graphics.zig");
|
||||
const mouse_encode = @import("mouse_encode.zig");
|
||||
const mouse_event = @import("mouse_event.zig");
|
||||
const osc = @import("osc.zig");
|
||||
const paste = @import("paste.zig");
|
||||
const render = @import("render.zig");
|
||||
const result = @import("result.zig");
|
||||
const row = @import("row.zig");
|
||||
@@ -192,6 +194,7 @@ const type_decls = [_]TypeDecl{
|
||||
.initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo),
|
||||
.initStruct("GhosttyMouseEncoderSize", mouse_encode.Size),
|
||||
.initStruct("GhosttyMousePosition", mouse_event.Position),
|
||||
.initStruct("GhosttyPaste", paste.Request),
|
||||
.initTaggedStruct("GhosttyPoint", point.Point.C, "tag", "value", .generated),
|
||||
.initStruct("GhosttyPointCoordinate", point.Coordinate),
|
||||
.initUnion("GhosttyPointValue", point.Point.CValue, point.Point.C),
|
||||
@@ -272,6 +275,7 @@ const type_decls = [_]TypeDecl{
|
||||
"GHOSTTY_OSC_COMMAND_",
|
||||
"TYPE_MAX_VALUE",
|
||||
),
|
||||
.initEnum("GhosttyPasteSource", paste_pkg.Source, "GHOSTTY_PASTE_SOURCE_"),
|
||||
.initEnum("GhosttyPointTag", point.Tag, "GHOSTTY_POINT_TAG_"),
|
||||
.initEnum("GhosttyRenderStateCursorVisualStyle", render.CursorVisualStyle, "GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_"),
|
||||
.initEnum("GhosttyRenderStateData", render.Data, "GHOSTTY_RENDER_STATE_DATA_"),
|
||||
|
||||
Reference in New Issue
Block a user