Merge branch 'ghostty-org:main' into main

This commit is contained in:
Mohammad AlShami
2026-08-24 10:05:25 +03:00
committed by Mohammad H. AlShami
29 changed files with 2613 additions and 243 deletions

4
.github/VOUCHED.td vendored
View File

@@ -245,6 +245,7 @@ nolinmcfarland
nouritsu
nwehg
ocean6954
ollioddi
oshdubh
otomn
paaloeye
@@ -277,6 +278,7 @@ rgehan
rhodes-b
rightaditya
rjwittams
rkoten
rmengelbrecht
rmunn
rockorager
@@ -313,6 +315,7 @@ tothedarktowercame
trag1c
tristan957
tsacha
tuananh
turbolent
tweedbeetle
uhojin
@@ -320,6 +323,7 @@ unphased
unsaltedscholar
uzaaft
vancluever
vasilmytsyk
vaughanandrews
-vectorpeak Stupid bot cosplaying as a maintainer
vegerot

View File

@@ -1,7 +1,13 @@
# 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. The clipboard's data is produced on demand through a
read callback, so only what is actually pasted is ever read, and the
result streams to the pty in chunks. 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

View File

@@ -1,7 +1,160 @@
#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]
// What the clipboard holds. A real embedder would keep a handle to the
// pasteboard or its items here; the data is only produced on demand.
typedef struct {
const char* text;
} clipboard_t;
// Produces the data of one representation when the terminal needs it.
// Only the text is ever read: the image is listed on a paste event
// but never requested, so a large image costs nothing to paste.
// Nothing written to the writer is retained, so the data can be
// streamed from anywhere in pieces of any size.
static bool read_clipboard(void* userdata, GhosttyString mime, GhosttyWriter writer) {
clipboard_t* clipboard = userdata;
if (mime.len == strlen("text/plain") &&
memcmp(mime.ptr, "text/plain", mime.len) == 0) {
// Stream the text in small pieces just to show that it works.
const uint8_t* data = (const uint8_t*)clipboard->text;
size_t len = strlen(clipboard->text);
for (size_t offset = 0; offset < len; offset += 4) {
size_t n = len - offset < 4 ? len - offset : 4;
if (!writer.write(writer.userdata, data + offset, n)) return false;
}
return true;
}
printf(" image read requested, which never happens\n");
return false;
}
// 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) {
clipboard_t clipboard = {.text = text};
GhosttyString mimes[] = {
// The first text representation is what a text paste writes.
GS("text/plain"),
// Listed on a paste event, never read.
GS("image/png"),
};
GhosttyPaste paste = {
.size = sizeof(paste),
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
.source = GHOSTTY_PASTE_SOURCE_CLIPBOARD,
.mimes = mimes,
.mimes_len = sizeof(mimes) / sizeof(mimes[0]),
.reader = {.read = read_clipboard, .userdata = &clipboard},
.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, in
// chunks as the text was read.
printf(" %s\n", written ? "written" : "nothing to paste");
}
//! [terminal-paste]
//! [paste-safety]
void safety_example() {
const char* safe_data = "hello world";
@@ -29,27 +182,87 @@ 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");
{
clipboard_t clipboard = {.text = "committed"};
GhosttyString mime = GS("text/plain");
GhosttyPaste paste = {
.size = sizeof(paste),
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
.source = GHOSTTY_PASTE_SOURCE_TEXT,
.mimes = &mime,
.mimes_len = 1,
.reader = {.read = read_clipboard, .userdata = &clipboard},
.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;

View File

@@ -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

View File

@@ -10,6 +10,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
/** @defgroup io I/O
*
@@ -98,6 +99,49 @@ typedef struct {
void* userdata;
} GhosttyWriter;
/**
* Read one MIME-typed representation of some content, streaming its
* bytes to a writer.
*
* The library calls this with the MIME type of the representation it
* needs. The callback writes all of that representation's data to
* @p writer, in as many calls to `writer.write(writer.userdata, data,
* len)` as is convenient (one call with everything or many small
* pieces both work), and returns true. Nothing written is retained
* beyond each write call, so the data may be borrowed from anywhere:
* a pasteboard item, a file being read, a stream.
*
* Returning false reports that the data could not be read. If the
* writer refuses a write (returns false), stop and return false
* without writing more.
*
* All pointer arguments, the mime, and the writer are borrowed and
* valid only for the duration of the callback. The callback is
* invoked synchronously on the calling thread. The API receiving the
* GhosttyMimeReader defines which MIME types are requested, how many
* times, and any consistency requirements across repeated reads.
*
* @param userdata Opaque userdata from GhosttyMimeReader
* @param mime The MIME type of the representation to read
* @param writer Where to write the data; valid only during this call
* @return true once all the data was written, false if it could not
* be read or the writer refused a write
*/
typedef bool (*GhosttyMimeReaderFn)(
void* userdata,
GhosttyString mime,
GhosttyWriter writer);
/**
* A MIME-typed content source callback and its opaque context.
*
* The struct is passed by value. @p read must be non-NULL.
*/
typedef struct {
GhosttyMimeReaderFn read;
void* userdata;
} GhosttyMimeReader;
#ifdef __cplusplus
}
#endif

View File

@@ -1,26 +1,67 @@
/**
* @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 the MIME types the clipboard holds (just `text/plain` for
* an ordinary paste), a GhosttyMimeReader that produces the data of
* any one of them, and where the paste 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. No data is read for the event.
* - 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.
*
* The data is pulled through GhosttyPaste::reader only when a
* representation is actually pasted (so a clipboard holding a large
* image next to some text costs nothing), and the encoded bytes
* stream to the write_pty callback (GHOSTTY_TERMINAL_OPT_WRITE_PTY)
* in chunks as they are produced, never in one piece. The callback
* may be invoked several times for a single paste; the pieces must be
* written to the pty in order.
*
* Text that could inject commands (a newline when unbracketed, or the
* bracketed paste terminator when bracketed) is refused with
* GHOSTTY_REJECTED and nothing written 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. Each call
* reads the text at most once and buffers it whole while the rule is
* applied, so the source needs no stability across reads (the
* confirmed retry simply pastes whatever the source holds then) and a
* refused or failed paste writes nothing at all.
*
* @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 +76,119 @@
#include <stdbool.h>
#include <stddef.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/io.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
* MIME type array and the strings it points to are borrowed only for
* the duration of the ghostty_terminal_paste() call, as is everything
* the reader produces.
*/
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 MIME types of the representations available,
* in preferred order. A text paste reads and writes the first entry
* with a text MIME type such as "text/plain" and ignores the rest. A
* paste event lists every entry and reads none. May be NULL when
* mimes_len is zero, which is nothing to paste.
*/
const GhosttyString* mimes;
/** Number of entries in mimes. */
size_t mimes_len;
/**
* Produces the data of a representation on demand. Required when
* mimes_len is nonzero.
*
* Called at most once per ghostty_terminal_paste() call: for the
* text representation being pasted, never for anything else and
* never for a paste event. The MIME type requested is always an
* entry of `mimes`, passed through exactly as given there (the same
* pointer and length), so the callback may identify the
* representation by pointer or by content. A false return fails the
* paste with GHOSTTY_IO_ERROR.
*/
GhosttyMimeReader reader;
/**
* 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
* streams through the write_pty callback in chunks. The viewport is not
* scrolled; that is up to the embedder, as for key input.
*
* A paste event records a session grant for its one-time password only
* once the event is written; a failed call never leaves a grant for an
* event that was never sent.
*
* @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, MIME
* types without a reader, or when no write_pty callback is
* installed; GHOSTTY_OUT_OF_MEMORY; GHOSTTY_IO_ERROR if the
* reader failed or there is no secure entropy source to mint a
* paste event password (wasm32-freestanding without
* GHOSTTY_SYS_OPT_RANDOM_SECURE set). Errors write nothing.
*/
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 +198,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 +225,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

View File

@@ -124,6 +124,24 @@ typedef bool (*GhosttySysDecodePngFn)(
size_t data_len,
GhosttySysImage* out);
/**
* Callback type for secure random bytes.
*
* Fills @p buf with @p len cryptographically secure random bytes. The
* library uses this for secrets, so it must be a real CSPRNG (getrandom,
* arc4random_buf, BCryptGenRandom, crypto.getRandomValues, ...); a
* predictable source is a security hole.
*
* @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
* @param buf Buffer to fill
* @param len Number of bytes to fill
* @return true if the buffer was filled, false if no entropy is available
*/
typedef bool (*GhosttySysRandomSecureFn)(
void* userdata,
uint8_t* buf,
size_t len);
/**
* System option identifiers for ghostty_sys_set().
*/
@@ -165,6 +183,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttySysLogFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_LOG = 2,
/**
* Override the secure random source.
*
* By default the library draws secure random bytes from the
* platform (getrandom or arc4random_buf on POSIX, CNG on Windows).
* Targets without one, such as wasm32-freestanding, have no default
* and operations that need entropy fail with GHOSTTY_IO_ERROR until
* this is set. When set,
* it is used instead of the platform source on every target. When
* cleared (NULL value), the platform default is restored.
*
* Input type: GhosttySysRandomSecureFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_RANDOM_SECURE = 3,
GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySysOption;

View File

@@ -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

View File

@@ -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;

362
po/bg.po
View File

@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-08-10 10:06-0500\n"
"PO-Revision-Date: 2026-02-09 22:07+0200\n"
"PO-Revision-Date: 2026-08-13 13:40+0300\n"
"Last-Translator: reo101 <pavel.atanasov2001@gmail.com>\n"
"Language-Team: Bulgarian <dict@ludost.net>\n"
"Language: bg\n"
@@ -175,7 +175,7 @@ msgstr "Раздели надясно"
#: src/apprt/gtk/ui/1.2/surface.blp:377
msgid "Close Split"
msgstr ""
msgstr "Затвори разделянето"
#: src/apprt/gtk/ui/1.2/surface.blp:383
msgid "Tab"
@@ -184,7 +184,7 @@ msgstr "Раздел"
#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232
#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464
msgid "Change Tab Title…"
msgstr "Смени името на таба…"
msgstr "Промени заглавието на раздела…"
#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60
#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237
@@ -203,7 +203,7 @@ msgstr "Прозорец"
#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215
msgid "Change Window Title…"
msgstr ""
msgstr "Промени заглавието на прозореца…"
#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220
#: src/input/command.zig:421
@@ -221,11 +221,11 @@ msgstr "Конфигурация"
#: src/apprt/gtk/ui/1.2/surface.blp:427 src/apprt/gtk/ui/1.5/window.blp:306
msgid "Open Configuration in OS Editor"
msgstr ""
msgstr "Отвори конфигурацията в редактора на операционната система"
#: src/apprt/gtk/ui/1.2/surface.blp:433 src/apprt/gtk/ui/1.5/window.blp:312
msgid "Open Configuration in New Window"
msgstr ""
msgstr "Отвори конфигурацията в нов прозорец"
#: src/apprt/gtk/ui/1.5/title-dialog.blp:5
msgid "Leave blank to restore the default title."
@@ -269,27 +269,27 @@ msgstr "Изпълни команда…"
#: src/apprt/gtk/class/application.zig:1767
msgid "The keybind was revoked by the system."
msgstr ""
msgstr "Клавишната комбинация е отменена от системата."
#: src/apprt/gtk/class/application.zig:1769
msgid "The keybind was denied by the system."
msgstr ""
msgstr "Клавишната комбинация е отказана от системата."
#: src/apprt/gtk/class/application.zig:1780
msgid "Global keybind unavailable"
msgstr ""
msgstr "Глобалната клавишна комбинация не е достъпна"
#: src/apprt/gtk/class/application.zig:2259
msgid "Export Terminal IO Events"
msgstr ""
msgstr "Експортиране на входно-изходните събития на терминала"
#: src/apprt/gtk/class/application.zig:2262
msgid "Export"
msgstr ""
msgstr "Експортирай"
#: src/apprt/gtk/class/application.zig:2783
msgid "Editing configuration file"
msgstr ""
msgstr "Редактиране на конфигурационния файл"
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198
msgid ""
@@ -371,11 +371,11 @@ msgstr "Промяна на заглавието на терминала"
#: src/apprt/gtk/class/title_dialog.zig:228
msgid "Change Tab Title"
msgstr "Смени името на таба"
msgstr "Промяна на заглавието на раздела"
#: src/apprt/gtk/class/title_dialog.zig:229
msgid "Change Window Title"
msgstr ""
msgstr "Промяна на заглавието на прозореца"
#: src/apprt/gtk/class/window.zig:1153
msgid "Reloaded the configuration"
@@ -395,710 +395,768 @@ msgstr "Разработчици на Ghostty"
#: src/input/command.zig:149
msgid "Reset Terminal"
msgstr ""
msgstr "Нулиране на терминала"
#: src/input/command.zig:150
msgid "Reset the terminal to a clean state."
msgstr ""
msgstr "Връщане на терминала в начално състояние."
#: src/input/command.zig:155
msgid "Copy to Clipboard"
msgstr ""
msgstr "Копиране в клипборда"
#: src/input/command.zig:156
msgid ""
"Copy the selected text to the clipboard in both plain and styled formats."
msgstr ""
"Копиране на избрания текст в клипборда както в обикновен, така и във "
"форматиран вид."
#: src/input/command.zig:159
msgid "Copy Selection as Plain Text to Clipboard"
msgstr ""
msgstr "Копиране на избраното като обикновен текст в клипборда"
#: src/input/command.zig:160
msgid "Copy the selected text as plain text to the clipboard."
msgstr ""
msgstr "Копиране на избрания текст като обикновен текст в клипборда."
#: src/input/command.zig:163
msgid "Copy Selection as ANSI Sequences to Clipboard"
msgstr ""
msgstr "Копиране на избраното с ANSI форматиране в клипборда"
#: src/input/command.zig:164
msgid "Copy the selected text as ANSI escape sequences to the clipboard."
msgstr ""
msgstr "Копиране на избрания текст с ANSI форматиране в клипборда."
#: src/input/command.zig:167
msgid "Copy Selection as HTML to Clipboard"
msgstr ""
msgstr "Копиране на избраното като HTML в клипборда"
#: src/input/command.zig:168
msgid "Copy the selected text as HTML to the clipboard."
msgstr ""
msgstr "Копиране на избрания текст като HTML в клипборда."
#: src/input/command.zig:173
msgid "Copy URL to Clipboard"
msgstr ""
msgstr "Копиране на URL адреса в клипборда"
#: src/input/command.zig:174
msgid "Copy the URL under the cursor to the clipboard."
msgstr ""
msgstr "Копиране на URL адреса под курсора в клипборда."
#: src/input/command.zig:179
msgid "Copy Terminal Title to Clipboard"
msgstr ""
msgstr "Копиране на заглавието на терминала в клипборда"
#: src/input/command.zig:180
msgid ""
"Copy the terminal title to the clipboard. If the terminal title is not set "
"this has no effect."
msgstr ""
"Копиране на заглавието на терминала в клипборда. Ако няма зададено заглавие, "
"действието няма ефект."
#: src/input/command.zig:185
msgid "Paste from Clipboard"
msgstr ""
msgstr "Поставяне от клипборда"
#: src/input/command.zig:186
msgid "Paste the contents of the main clipboard."
msgstr ""
msgstr "Поставяне на съдържанието на основния клипборд."
#: src/input/command.zig:191
msgid "Paste from Selection"
msgstr ""
msgstr "Поставяне от избраното"
#: src/input/command.zig:192
msgid "Paste the contents of the selection clipboard."
msgstr ""
msgstr "Поставяне на съдържанието на клипборда за избраното."
#: src/input/command.zig:197
msgid "Start Search"
msgstr ""
msgstr "Начало на търсенето"
#: src/input/command.zig:198
msgid "Start a search if one isn't already active."
msgstr ""
msgstr "Започване на търсене, ако в момента няма активно такова."
#: src/input/command.zig:203
msgid "Search Selection"
msgstr ""
msgstr "Търсене на избраното"
#: src/input/command.zig:204
msgid "Start a search for the current text selection."
msgstr ""
msgstr "Започване на търсене на текущо избрания текст."
#: src/input/command.zig:209
msgid "End Search"
msgstr ""
msgstr "Край на търсенето"
#: src/input/command.zig:210
msgid "End the current search if any and hide any GUI elements."
msgstr ""
"Прекратяване на текущото търсене, ако има такова, и скриване на всички "
"елементи на графичния интерфейс."
#: src/input/command.zig:215
msgid "Next Search Result"
msgstr ""
msgstr "Следващ резултат от търсенето"
#: src/input/command.zig:216
msgid "Navigate to the next search result, if any."
msgstr ""
msgstr "Преминаване към следващия резултат от търсенето, ако има такъв."
#: src/input/command.zig:219
msgid "Previous Search Result"
msgstr ""
msgstr "Предишен резултат от търсенето"
#: src/input/command.zig:220
msgid "Navigate to the previous search result, if any."
msgstr ""
msgstr "Преминаване към предишния резултат от търсенето, ако има такъв."
#: src/input/command.zig:225
msgid "Increase Font Size"
msgstr ""
msgstr "Увеличаване на размера на шрифта"
#: src/input/command.zig:226
msgid "Increase the font size by 1 point."
msgstr ""
msgstr "Увеличаване на размера на шрифта с 1 пункт."
#: src/input/command.zig:231
msgid "Decrease Font Size"
msgstr ""
msgstr "Намаляване на размера на шрифта"
#: src/input/command.zig:232
msgid "Decrease the font size by 1 point."
msgstr ""
msgstr "Намаляване на размера на шрифта с 1 пункт."
#: src/input/command.zig:237
msgid "Reset Font Size"
msgstr ""
msgstr "Възстановяване на размера на шрифта"
#: src/input/command.zig:238
msgid "Reset the font size to the default."
msgstr ""
msgstr "Възстановяване на размера на шрифта до стойността по подразбиране."
#: src/input/command.zig:243
msgid "Clear Screen"
msgstr ""
msgstr "Изчистване на екрана"
#: src/input/command.zig:244
msgid "Clear the screen and scrollback."
msgstr ""
msgstr "Изчистване на екрана и буфера за превъртане назад."
#: src/input/command.zig:249
msgid "Select All"
msgstr ""
msgstr "Избиране на всичко"
#: src/input/command.zig:250
msgid "Select all text on the screen."
msgstr ""
msgstr "Избиране на целия текст на екрана."
#: src/input/command.zig:255
msgid "Scroll to Top"
msgstr ""
msgstr "Превъртане до началото"
#: src/input/command.zig:256
msgid "Scroll to the top of the screen."
msgstr ""
msgstr "Превъртане до началото на екрана."
#: src/input/command.zig:261
msgid "Scroll to Bottom"
msgstr ""
msgstr "Превъртане до края"
#: src/input/command.zig:262
msgid "Scroll to the bottom of the screen."
msgstr ""
msgstr "Превъртане до края на екрана."
#: src/input/command.zig:267
msgid "Scroll to Selection"
msgstr ""
msgstr "Превъртане до избраното"
#: src/input/command.zig:268
msgid "Scroll to the selected text."
msgstr ""
msgstr "Превъртане до избрания текст."
#: src/input/command.zig:273
msgid "Scroll Page Up"
msgstr ""
msgstr "Превъртане с една страница нагоре"
#: src/input/command.zig:274
msgid "Scroll the screen up by a page."
msgstr ""
msgstr "Превъртане на екрана с една страница нагоре."
#: src/input/command.zig:279
msgid "Scroll Page Down"
msgstr ""
msgstr "Превъртане с една страница надолу"
#: src/input/command.zig:280
msgid "Scroll the screen down by a page."
msgstr ""
msgstr "Превъртане на екрана с една страница надолу."
#: src/input/command.zig:286
msgid "Copy Screen to Temporary File and Copy Path"
msgstr ""
msgstr "Записване на екрана във временен файл и копиране на пътя"
#: src/input/command.zig:287
msgid ""
"Copy the screen contents to a temporary file and copy the path to the "
"clipboard."
msgstr ""
"Съдържанието на екрана се записва във временен файл, а пътят до файла се "
"копира в клипборда."
#: src/input/command.zig:291
msgid "Copy Screen to Temporary File and Paste Path"
msgstr ""
msgstr "Записване на екрана във временен файл и поставяне на пътя"
#: src/input/command.zig:292
msgid ""
"Copy the screen contents to a temporary file and paste the path to the file."
msgstr ""
"Съдържанието на екрана се записва във временен файл и се поставя пътят до "
"файла."
#: src/input/command.zig:296
msgid "Copy Screen to Temporary File and Open"
msgstr ""
msgstr "Записване на екрана във временен файл и отваряне на файла"
#: src/input/command.zig:297
msgid "Copy the screen contents to a temporary file and open it."
msgstr ""
"Съдържанието на екрана се записва във временен файл, който след това се "
"отваря."
#: src/input/command.zig:305
msgid "Copy Screen as HTML to Temporary File and Copy Path"
msgstr ""
msgstr "Записване на екрана като HTML във временен файл и копиране на пътя"
#: src/input/command.zig:306
msgid ""
"Copy the screen contents as HTML to a temporary file and copy the path to "
"the clipboard."
msgstr ""
"Съдържанието на екрана се записва във временен файл в HTML формат, а пътят "
"до файла се копира в клипборда."
#: src/input/command.zig:313
msgid "Copy Screen as HTML to Temporary File and Paste Path"
msgstr ""
msgstr "Записване на екрана като HTML във временен файл и поставяне на пътя"
#: src/input/command.zig:314
msgid ""
"Copy the screen contents as HTML to a temporary file and paste the path to "
"the file."
msgstr ""
"Съдържанието на екрана се записва във временен файл в HTML формат и се "
"поставя пътят до файла."
#: src/input/command.zig:321
msgid "Copy Screen as HTML to Temporary File and Open"
msgstr ""
msgstr "Записване на екрана като HTML във временен файл и отваряне на файла"
#: src/input/command.zig:322
msgid "Copy the screen contents as HTML to a temporary file and open it."
msgstr ""
"Съдържанието на екрана се записва във временен файл в HTML формат, който "
"след това се отваря."
#: src/input/command.zig:330
msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path"
msgstr ""
"Записване на екрана с ANSI форматиране във временен файл и копиране на пътя"
#: src/input/command.zig:331
msgid ""
"Copy the screen contents as ANSI escape sequences to a temporary file and "
"copy the path to the clipboard."
msgstr ""
"Съдържанието на екрана се записва във временен файл с ANSI форматиране, а "
"пътят до файла се копира в клипборда."
#: src/input/command.zig:338
msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path"
msgstr ""
"Записване на екрана с ANSI форматиране във временен файл и поставяне на пътя"
#: src/input/command.zig:339
msgid ""
"Copy the screen contents as ANSI escape sequences to a temporary file and "
"paste the path to the file."
msgstr ""
"Съдържанието на екрана се записва във временен файл с ANSI форматиране и се "
"поставя пътят до файла."
#: src/input/command.zig:346
msgid "Copy Screen as ANSI Sequences to Temporary File and Open"
msgstr ""
"Записване на екрана с ANSI форматиране във временен файл и отваряне на файла"
#: src/input/command.zig:347
msgid ""
"Copy the screen contents as ANSI escape sequences to a temporary file and "
"open it."
msgstr ""
"Съдържанието на екрана се записва във временен файл с ANSI форматиране, "
"който след това се отваря."
#: src/input/command.zig:354
msgid "Copy Selection to Temporary File and Copy Path"
msgstr ""
msgstr "Записване на избраното във временен файл и копиране на пътя"
#: src/input/command.zig:355
msgid ""
"Copy the selection contents to a temporary file and copy the path to the "
"clipboard."
msgstr ""
"Избраното съдържание се записва във временен файл, а пътят до файла се "
"копира в клипборда."
#: src/input/command.zig:359
msgid "Copy Selection to Temporary File and Paste Path"
msgstr ""
msgstr "Записване на избраното във временен файл и поставяне на пътя"
#: src/input/command.zig:360
msgid ""
"Copy the selection contents to a temporary file and paste the path to the "
"file."
msgstr ""
"Избраното съдържание се записва във временен файл и се поставя пътят до "
"файла."
#: src/input/command.zig:364
msgid "Copy Selection to Temporary File and Open"
msgstr ""
msgstr "Записване на избраното във временен файл и отваряне на файла"
#: src/input/command.zig:365
msgid "Copy the selection contents to a temporary file and open it."
msgstr ""
"Избраното съдържание се записва във временен файл, който след това се отваря."
#: src/input/command.zig:373
msgid "Copy Selection as HTML to Temporary File and Copy Path"
msgstr ""
msgstr "Записване на избраното като HTML във временен файл и копиране на пътя"
#: src/input/command.zig:374
msgid ""
"Copy the selection contents as HTML to a temporary file and copy the path to "
"the clipboard."
msgstr ""
"Избраното съдържание се записва във временен файл в HTML формат, а пътят до "
"файла се копира в клипборда."
#: src/input/command.zig:381
msgid "Copy Selection as HTML to Temporary File and Paste Path"
msgstr ""
msgstr "Записване на избраното като HTML във временен файл и поставяне на пътя"
#: src/input/command.zig:382
msgid ""
"Copy the selection contents as HTML to a temporary file and paste the path "
"to the file."
msgstr ""
"Избраното съдържание се записва във временен файл в HTML формат и се поставя "
"пътят до файла."
#: src/input/command.zig:389
msgid "Copy Selection as HTML to Temporary File and Open"
msgstr ""
msgstr "Записване на избраното като HTML във временен файл и отваряне на файла"
#: src/input/command.zig:390
msgid "Copy the selection contents as HTML to a temporary file and open it."
msgstr ""
"Избраното съдържание се записва във временен файл в HTML формат, който след "
"това се отваря."
#: src/input/command.zig:398
msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path"
msgstr ""
"Записване на избраното с ANSI форматиране във временен файл и копиране на "
"пътя"
#: src/input/command.zig:399
msgid ""
"Copy the selection contents as ANSI escape sequences to a temporary file and "
"copy the path to the clipboard."
msgstr ""
"Избраното съдържание се записва във временен файл с ANSI форматиране, а "
"пътят до файла се копира в клипборда."
#: src/input/command.zig:406
msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path"
msgstr ""
"Записване на избраното с ANSI форматиране във временен файл и поставяне на "
"пътя"
#: src/input/command.zig:407
msgid ""
"Copy the selection contents as ANSI escape sequences to a temporary file and "
"paste the path to the file."
msgstr ""
"Избраното съдържание се записва във временен файл с ANSI форматиране и се "
"поставя пътят до файла."
#: src/input/command.zig:414
msgid "Copy Selection as ANSI Sequences to Temporary File and Open"
msgstr ""
"Записване на избраното с ANSI форматиране във временен файл и отваряне на "
"файла"
#: src/input/command.zig:415
msgid ""
"Copy the selection contents as ANSI escape sequences to a temporary file and "
"open it."
msgstr ""
"Избраното съдържание се записва във временен файл с ANSI форматиране, който "
"след това се отваря."
#: src/input/command.zig:422
msgid "Open a new window."
msgstr ""
msgstr "Отваряне на нов прозорец."
#: src/input/command.zig:428
msgid "Open a new tab."
msgstr ""
msgstr "Отваряне на нов раздел."
#: src/input/command.zig:434
msgid "Move Tab Left"
msgstr ""
msgstr "Преместване на раздела наляво"
#: src/input/command.zig:435
msgid "Move the current tab to the left."
msgstr ""
msgstr "Преместване на текущия раздел наляво."
#: src/input/command.zig:439
msgid "Move Tab Right"
msgstr ""
msgstr "Преместване на раздела надясно"
#: src/input/command.zig:440
msgid "Move the current tab to the right."
msgstr ""
msgstr "Преместване на текущия раздел надясно."
#: src/input/command.zig:446
msgid "Move Tab to New Window"
msgstr ""
msgstr "Преместване на раздела в нов прозорец"
#: src/input/command.zig:447
msgid "Move the current tab to a new window."
msgstr ""
msgstr "Преместване на текущия раздел в нов прозорец."
#: src/input/command.zig:452
msgid "Toggle Tab Overview"
msgstr ""
msgstr "Превключване на прегледа на разделите"
#: src/input/command.zig:453
msgid "Toggle the tab overview."
msgstr ""
msgstr "Превключване на прегледа на разделите."
#: src/input/command.zig:458
msgid "Change Terminal Title…"
msgstr ""
msgstr "Промяна на заглавието на терминала…"
#: src/input/command.zig:459
msgid "Prompt for a new title for the current terminal."
msgstr ""
msgstr "Показване на подкана за ново заглавие на текущия терминал."
#: src/input/command.zig:465
msgid "Prompt for a new title for the current tab."
msgstr ""
msgstr "Показване на подкана за ново заглавие на текущия раздел."
#: src/input/command.zig:478
msgid "Split the terminal to the left."
msgstr ""
msgstr "Разделяне на терминала наляво."
#: src/input/command.zig:483
msgid "Split the terminal to the right."
msgstr ""
msgstr "Разделяне на терминала надясно."
#: src/input/command.zig:488
msgid "Split the terminal up."
msgstr ""
msgstr "Разделяне на терминала нагоре."
#: src/input/command.zig:493
msgid "Split the terminal down."
msgstr ""
msgstr "Разделяне на терминала надолу."
#: src/input/command.zig:500
msgid "Focus Split: Previous"
msgstr ""
msgstr "Фокусиране на предишното разделяне"
#: src/input/command.zig:501
msgid "Focus the previous split, if any."
msgstr ""
msgstr "Фокусиране на предишното разделяне, ако има такова."
#: src/input/command.zig:505
msgid "Focus Split: Next"
msgstr ""
msgstr "Фокусиране на следващото разделяне"
#: src/input/command.zig:506
msgid "Focus the next split, if any."
msgstr ""
msgstr "Фокусиране на следващото разделяне, ако има такова."
#: src/input/command.zig:510
msgid "Focus Split: Left"
msgstr ""
msgstr "Фокусиране на разделянето отляво"
#: src/input/command.zig:511
msgid "Focus the split to the left, if it exists."
msgstr ""
msgstr "Фокусиране на разделянето отляво, ако съществува."
#: src/input/command.zig:515
msgid "Focus Split: Right"
msgstr ""
msgstr "Фокусиране на разделянето отдясно"
#: src/input/command.zig:516
msgid "Focus the split to the right, if it exists."
msgstr ""
msgstr "Фокусиране на разделянето отдясно, ако съществува."
#: src/input/command.zig:520
msgid "Focus Split: Up"
msgstr ""
msgstr "Фокусиране на разделянето отгоре"
#: src/input/command.zig:521
msgid "Focus the split above, if it exists."
msgstr ""
msgstr "Фокусиране на разделянето отгоре, ако съществува."
#: src/input/command.zig:525
msgid "Focus Split: Down"
msgstr ""
msgstr "Фокусиране на разделянето отдолу"
#: src/input/command.zig:526
msgid "Focus the split below, if it exists."
msgstr ""
msgstr "Фокусиране на разделянето отдолу, ако съществува."
#: src/input/command.zig:533
msgid "Focus Window: Previous"
msgstr ""
msgstr "Фокусиране на предишния прозорец"
#: src/input/command.zig:534
msgid "Focus the previous window, if any."
msgstr ""
msgstr "Фокусиране на предишния прозорец, ако има такъв."
#: src/input/command.zig:538
msgid "Focus Window: Next"
msgstr ""
msgstr "Фокусиране на следващия прозорец"
#: src/input/command.zig:539
msgid "Focus the next window, if any."
msgstr ""
msgstr "Фокусиране на следващия прозорец, ако има такъв."
#: src/input/command.zig:545
msgid "Toggle Split Zoom"
msgstr ""
msgstr "Превключване на увеличението на разделянето"
#: src/input/command.zig:546
msgid "Toggle the zoom state of the current split."
msgstr ""
msgstr "Превключване на увеличението на текущото разделяне."
#: src/input/command.zig:551
msgid "Toggle Read-Only Mode"
msgstr ""
msgstr "Превключване на режима само за четене"
#: src/input/command.zig:552
msgid "Toggle read-only mode for the current surface."
msgstr ""
msgstr "Превключване на режима само за четене за текущия терминал."
#: src/input/command.zig:557
msgid "Equalize Splits"
msgstr ""
msgstr "Изравняване на разделянията"
#: src/input/command.zig:558
msgid "Equalize the size of all splits."
msgstr ""
msgstr "Изравняване на размера на всички разделяния."
#: src/input/command.zig:563
msgid "Reset Window Size"
msgstr ""
msgstr "Възстановяване на размера на прозореца"
#: src/input/command.zig:564
msgid "Reset the window size to the default."
msgstr ""
msgstr "Възстановяване на размера на прозореца до стойността по подразбиране."
#: src/input/command.zig:569
msgid "Toggle Inspector"
msgstr ""
msgstr "Превключване на инспектора"
#: src/input/command.zig:570
msgid "Toggle the inspector."
msgstr ""
msgstr "Превключване на инспектора."
#: src/input/command.zig:575
msgid "Show the GTK Inspector"
msgstr ""
msgstr "Показване на GTK инспектора"
#: src/input/command.zig:576
msgid "Show the GTK inspector."
msgstr ""
msgstr "Показване на GTK инспектора."
#: src/input/command.zig:581
msgid "Show On-Screen Keyboard"
msgstr ""
msgstr "Показване на екранната клавиатура"
#: src/input/command.zig:582
msgid "Show the on-screen keyboard if present."
msgstr ""
msgstr "Показване на екранната клавиатура, ако е налична."
#: src/input/command.zig:588
msgid "Open Config Using OS editor"
msgstr ""
msgstr "Отваряне на конфигурацията с редактора на операционната система"
#: src/input/command.zig:589
msgid "Open the config file with the OS's default editor."
msgstr ""
"Отваряне на конфигурационния файл с редактора по подразбиране на "
"операционната система."
#: src/input/command.zig:593
msgid "Open Config in New Terminal Window"
msgstr ""
msgstr "Отваряне на конфигурацията в нов терминален прозорец"
#: src/input/command.zig:594
msgid "Open the config file in a new window using $EDITOR or $VISUAL."
msgstr ""
"Отваряне на конфигурационния файл в нов прозорец чрез $EDITOR или $VISUAL."
#: src/input/command.zig:600
msgid "Reload Config"
msgstr ""
msgstr "Презареждане на конфигурацията"
#: src/input/command.zig:601
msgid "Reload the config file."
msgstr ""
msgstr "Презареждане на конфигурационния файл."
#: src/input/command.zig:606
msgid "Close Terminal"
msgstr ""
msgstr "Затваряне на терминала"
#: src/input/command.zig:607
msgid "Close the current terminal."
msgstr ""
msgstr "Затваряне на текущия терминал."
#: src/input/command.zig:614
msgid "Close the current tab."
msgstr ""
msgstr "Затваряне на текущия раздел."
#: src/input/command.zig:618
msgid "Close Other Tabs"
msgstr ""
msgstr "Затваряне на другите раздели"
#: src/input/command.zig:619
msgid "Close all tabs in this window except the current one."
msgstr ""
msgstr "Затваряне на всички раздели в този прозорец освен текущия."
#: src/input/command.zig:623
msgid "Close Tabs to the Right"
msgstr ""
msgstr "Затваряне на разделите отдясно"
#: src/input/command.zig:624
msgid "Close all tabs to the right of the current one."
msgstr ""
msgstr "Затваряне на всички раздели вдясно от текущия."
#: src/input/command.zig:631
msgid "Close the current window."
msgstr ""
msgstr "Затваряне на текущия прозорец."
#: src/input/command.zig:636
msgid "Close All Windows"
msgstr ""
msgstr "Затваряне на всички прозорци"
#: src/input/command.zig:637
msgid "Close all windows."
msgstr ""
msgstr "Затваряне на всички прозорци."
#: src/input/command.zig:642
msgid "Toggle Maximize"
msgstr ""
msgstr "Превключване на максимизирането"
#: src/input/command.zig:643
msgid "Toggle the maximized state of the current window."
msgstr ""
msgstr "Превключване на максимизираното състояние на текущия прозорец."
#: src/input/command.zig:648
msgid "Toggle Fullscreen"
msgstr ""
msgstr "Превключване на цял екран"
#: src/input/command.zig:649
msgid "Toggle the fullscreen state of the current window."
msgstr ""
msgstr "Превключване на състоянието на цял екран на текущия прозорец."
#: src/input/command.zig:654
msgid "Toggle Window Decorations"
msgstr ""
msgstr "Превключване на декорациите на прозореца"
#: src/input/command.zig:655
msgid "Toggle the window decorations."
msgstr ""
msgstr "Показване или скриване на декорациите на прозореца."
#: src/input/command.zig:660
msgid "Toggle Float on Top"
msgstr ""
msgstr "Превключване на задържането на прозореца най-отгоре"
#: src/input/command.zig:661
msgid "Toggle the float on top state of the current window."
msgstr ""
"Превключване дали текущият прозорец да остава над всички останали прозорци."
#: src/input/command.zig:666
msgid "Toggle Secure Input"
msgstr ""
msgstr "Превключване на защитеното въвеждане"
#: src/input/command.zig:667
msgid "Toggle secure input mode."
msgstr ""
msgstr "Превключване на режима за защитено въвеждане."
#: src/input/command.zig:672
msgid "Toggle Mouse Reporting"
msgstr ""
msgstr "Превключване на предаването на събития от мишката"
#: src/input/command.zig:673
msgid "Toggle whether mouse events are reported to terminal applications."
msgstr ""
"Превключване дали събитията от мишката да се предават на приложенията в "
"терминала."
#: src/input/command.zig:678
msgid "Toggle Background Opacity"
msgstr ""
msgstr "Превключване на прозрачността на фона"
#: src/input/command.zig:679
msgid "Toggle the background opacity of a window that started transparent."
msgstr ""
"Превключване между прозрачен и непрозрачен фон за прозорец, стартиран с "
"прозрачен фон."
#: src/input/command.zig:684
msgid "Check for Updates"
msgstr ""
msgstr "Проверка за обновления"
#: src/input/command.zig:685
msgid "Check for updates to the application."
msgstr ""
msgstr "Проверка за обновления на приложението."
#: src/input/command.zig:690
msgid "Undo"
msgstr ""
msgstr "Отмяна"
#: src/input/command.zig:691
msgid "Undo the last action."
msgstr ""
msgstr "Отмяна на последното действие."
#: src/input/command.zig:696
msgid "Redo"
msgstr ""
msgstr "Повтаряне"
#: src/input/command.zig:697
msgid "Redo the last undone action."
msgstr ""
msgstr "Повтаряне на последното отменено действие."
#: src/input/command.zig:703
msgid "Quit the application."
msgstr ""
msgstr "Излизане от приложението."
#: src/input/command.zig:708
msgid "Ghostty"
msgstr ""
msgstr "Ghostty"
#: src/input/command.zig:709
msgid "Put a little Ghostty in your terminal."
msgstr ""
msgstr "Добавете малко Ghostty в терминала си."

View File

@@ -1,6 +1,14 @@
const std = @import("std");
const Terminal = @import("../terminal/Terminal.zig");
/// The bracketed paste (mode 2004) frame written around the data.
pub const bracketed_prefix = "\x1b[200~";
pub const bracketed_suffix = "\x1b[201~";
/// The maximum number of bytes `encode` adds around the data, so callers
/// can size a buffer for the full encoded result.
pub const max_frame_size = bracketed_prefix.len + bracketed_suffix.len;
pub const Options = struct {
/// True if bracketed paste mode is on.
bracketed: bool,
@@ -93,8 +101,8 @@ pub fn encode(
// Bracketed paste mode (mode 2004) wraps pasted data in
// fenceposts so that the terminal can ignore things like newlines.
if (opts.bracketed) {
result[0] = "\x1b[200~";
result[2] = "\x1b[201~";
result[0] = bracketed_prefix;
result[2] = bracketed_suffix;
return result;
}
@@ -116,6 +124,42 @@ pub const Error = error{
MutableRequired,
};
/// Encode the given data for pasting directly into a writer. This is
/// the same transformation as `encode` (unsafe bytes replaced, bracketed
/// frame or newline conversion per `opts`) but the data is copied
/// exactly once: into the writer's buffer, where it is modified in place.
/// This is the form to use when the data is const and the result is
/// being assembled into a single buffer anyway.
///
/// The data is copied in chunks sized to the writer's buffer, so any
/// writer works; a writer with less total capacity than the writer
/// needs to hold at once reports `error.WriteFailed` as usual.
///
/// WARNING: The input data is not checked for safety. See `isSafe`
/// and `isSafeWith` to check if the data is safe to paste.
pub fn encodeWriter(
writer: *std.Io.Writer,
data: []const u8,
opts: Options,
) std.Io.Writer.Error!void {
if (opts.bracketed) try writer.writeAll(bracketed_prefix);
// The byte transformations are position-independent, so the data
// can be copied and encoded chunk by chunk. The frame returned by
// encode is ignored since it's written around the whole data here.
var remaining = data;
while (remaining.len > 0) {
const dest = try writer.writableSliceGreedy(1);
const n = @min(dest.len, remaining.len);
@memcpy(dest[0..n], remaining[0..n]);
_ = encode(dest[0..n], opts);
writer.advance(n);
remaining = remaining[n..];
}
if (opts.bracketed) try writer.writeAll(bracketed_suffix);
}
/// Returns true if the data looks safe to paste. Data is considered
/// unsafe if it contains any of the following:
///
@@ -133,6 +177,22 @@ pub fn isSafe(data: []const u8) bool {
std.mem.indexOf(u8, data, "\x1b[201~") == null;
}
/// Returns true if the data looks safe to paste given how it will be
/// encoded. This is the terminal-state-aware counterpart of `isSafe`:
///
/// - Bracketed (mode 2004 on): the program receives the data as one
/// framed unit, so newlines are fine. The data is unsafe only if it
/// contains the end of the frame (`\x1b[201~`), which would let the
/// rest of the data escape the frame and inject commands.
/// - Unbracketed: the same rule as `isSafe`.
///
/// Callers wanting the conservative rule regardless of terminal state
/// should use `isSafe` instead.
pub fn isSafeWith(data: []const u8, opts: Options) bool {
if (opts.bracketed) return std.mem.indexOf(u8, data, bracketed_suffix) == null;
return isSafe(data);
}
test isSafe {
const testing = std.testing;
try testing.expect(isSafe("hello"));
@@ -141,6 +201,110 @@ test isSafe {
try testing.expect(!isSafe("he\x1b[201~llo"));
}
test isSafeWith {
const testing = std.testing;
// Bracketed: newlines are fine, the frame terminator is not.
try testing.expect(isSafeWith("hello", .{ .bracketed = true }));
try testing.expect(isSafeWith("hello\nworld", .{ .bracketed = true }));
try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = true }));
try testing.expect(!isSafeWith("hello\n\x1b[201~", .{ .bracketed = true }));
// Unbracketed: the conservative rule.
try testing.expect(isSafeWith("hello", .{ .bracketed = false }));
try testing.expect(!isSafeWith("hello\nworld", .{ .bracketed = false }));
try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = false }));
}
test "encodeWriter bracketed" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeWriter(&writer, "hel\x1blo\nworld", .{ .bracketed = true });
try testing.expectEqualStrings("\x1b[200~hel lo\nworld\x1b[201~", writer.buffered());
}
test "encodeWriter unbracketed" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeWriter(&writer, "hel\x00lo\r\nworld", .{ .bracketed = false });
try testing.expectEqualStrings("hel lo\r\rworld", writer.buffered());
}
test "encodeWriter empty" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeWriter(&writer, "", .{ .bracketed = true });
try testing.expectEqualStrings("\x1b[200~\x1b[201~", writer.buffered());
writer = .fixed(&buf);
try encodeWriter(&writer, "", .{ .bracketed = false });
try testing.expectEqualStrings("", writer.buffered());
}
test "encodeWriter chunks through a small writer buffer" {
const testing = std.testing;
const alloc = testing.allocator;
// A writer with a 4-byte staging buffer that drains into a list,
// so the data is copied and encoded in several chunks.
const Sink = struct {
list: std.ArrayList(u8) = .empty,
writer: std.Io.Writer,
fn drain(
w: *std.Io.Writer,
data: []const []const u8,
splat: usize,
) std.Io.Writer.Error!usize {
const self: *@This() = @alignCast(@fieldParentPtr("writer", w));
self.list.appendSlice(testing.allocator, w.buffered()) catch return error.WriteFailed;
w.end = 0;
var n: usize = 0;
for (data[0 .. data.len - 1]) |slice| {
self.list.appendSlice(testing.allocator, slice) catch return error.WriteFailed;
n += slice.len;
}
for (0..splat) |_| {
self.list.appendSlice(testing.allocator, data[data.len - 1]) catch return error.WriteFailed;
}
return n + splat * data[data.len - 1].len;
}
};
var staging: [4]u8 = undefined;
var sink: Sink = .{ .writer = .{
.buffer = &staging,
.vtable = &.{ .drain = Sink.drain },
} };
defer sink.list.deinit(alloc);
const data = "line one\nline\x1btwo\nline three\n";
try encodeWriter(&sink.writer, data, .{ .bracketed = true });
try sink.writer.flush();
try testing.expectEqualStrings(
"\x1b[200~line one\nline two\nline three\n\x1b[201~",
sink.list.items,
);
}
test "encodeWriter too small" {
const testing = std.testing;
var buf: [4]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try testing.expectError(
error.WriteFailed,
encodeWriter(&writer, "hello", .{ .bracketed = true }),
);
}
test max_frame_size {
const testing = std.testing;
const result = try encode(@as([]const u8, ""), .{ .bracketed = true });
try testing.expectEqual(max_frame_size, result[0].len + result[2].len);
}
test "encode bracketed" {
const testing = std.testing;
const result = try encode(

View File

@@ -161,7 +161,7 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{
.progressParentFile = Io.failingProgressParentFile,
.random = Io.noRandom,
.randomSecure = Io.failingRandomSecure,
.randomSecure = randomSecure,
.now = Io.noNow,
.clockResolution = Io.failingClockResolution,
@@ -212,6 +212,35 @@ fn swapCancelProtection(
fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {}
fn randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void {
if (buffer.len == 0) return;
// The same sources as `std.Io.Threaded.randomSecure` minus
// cancelation and the /dev/urandom fallback: arc4random_buf where
// libc provides it (all the BSDs and Darwin, glibc 2.36+), otherwise
// the getrandom syscall on Linux. Anything else has no entropy.
if (builtin.link_libc and @TypeOf(posix.system.arc4random_buf) != void) {
posix.system.arc4random_buf(buffer.ptr, buffer.len);
return;
}
if (builtin.os.tag == .linux) {
const linux = std.os.linux;
var i: usize = 0;
while (i < buffer.len) {
const rc = linux.getrandom(buffer[i..].ptr, buffer.len - i, 0);
switch (linux.errno(rc)) {
.SUCCESS => i += rc,
.INTR => continue,
else => return error.EntropyUnavailable,
}
}
return;
}
return error.EntropyUnavailable;
}
fn closeFd(fd: posix.fd_t) void {
// Never retry close on EINTR: POSIX leaves the fd state unspecified
// and Linux always closes it, so retrying risks closing an unrelated
@@ -965,6 +994,28 @@ test "Io.Mutex through TinyIo" {
test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1);
}
test "randomSecure fills with fresh entropy" {
if (comptime !supported) return error.SkipZigTest;
const tio: TinyIo = .init;
const test_io = tio.io();
const testing = std.testing;
var a: [32]u8 = @splat(0);
var b: [32]u8 = @splat(0);
try test_io.randomSecure(&a);
try test_io.randomSecure(&b);
// Non-zero and non-repeating. A zero fill is what `random` does
// without a source, which would make every one-time password the
// same; identical draws would mean the same thing.
try testing.expect(!std.mem.allEqual(u8, &a, 0));
try testing.expect(!std.mem.allEqual(u8, &b, 0));
try testing.expect(!std.mem.eql(u8, &a, &b));
// Zero-length is a no-op.
try test_io.randomSecure(a[0..0]);
}
test "unused operations fail gracefully" {
if (comptime !supported) return error.SkipZigTest;
const tio: TinyIo = .init;

View File

@@ -95,6 +95,10 @@ pub const TerminalStream = terminal.TerminalStream;
pub const Stream = terminal.Stream;
pub const StreamAction = terminal.StreamAction;
pub const UnknownSequence = terminal.UnknownSequence;
pub const Paste = terminal.Paste;
pub const PasteSource = terminal.PasteSource;
pub const PasteError = terminal.PasteError;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = terminal.CursorStyle;
@@ -129,8 +133,11 @@ pub const input = struct {
// Paste-related APIs
pub const PasteError = paste.Error;
pub const PasteOptions = paste.Options;
pub const max_paste_frame_size = paste.max_frame_size;
pub const isSafePaste = paste.isSafe;
pub const isSafePasteWith = paste.isSafeWith;
pub const encodePaste = paste.encode;
pub const encodePasteWriter = paste.encodeWriter;
// Key encoding
pub const Key = key.Key;
@@ -208,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" });

View File

@@ -44,6 +44,23 @@ pub const Writer = extern struct {
}
};
/// C: GhosttyMimeReaderFn
pub const MimeReaderFn = *const fn (
userdata: ?*anyopaque,
mime: lib.String,
writer: Writer,
) callconv(lib.calling_conv) bool;
/// C: GhosttyMimeReader
pub const MimeReader = extern struct {
read: ?MimeReaderFn = null,
userdata: ?*anyopaque = null,
pub fn valid(self: MimeReader) bool {
return self.read != null;
}
};
/// Adapts a `GhosttyReader` to `std.Io.Reader`.
///
/// The adapter must have a stable address while `interface` is in use. Its
@@ -418,6 +435,8 @@ test "C reader and writer layouts keep callback first" {
try std.testing.expectEqual(@sizeOf(?ReaderFn) + @sizeOf(?*anyopaque), @sizeOf(Reader));
try std.testing.expectEqual(@as(usize, 0), @offsetOf(Writer, "write"));
try std.testing.expectEqual(@as(usize, 0), @offsetOf(MimeReader, "read"));
try std.testing.expectEqual(@sizeOf(?MimeReaderFn), @offsetOf(MimeReader, "userdata"));
try std.testing.expectEqual(@sizeOf(?WriterFn), @offsetOf(Writer, "userdata"));
try std.testing.expectEqual(@sizeOf(?WriterFn) + @sizeOf(?*anyopaque), @sizeOf(Writer));
}

View File

@@ -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;

View File

@@ -1,8 +1,130 @@
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 io = @import("io.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. The flat C form of the Zig tagged union
/// (terminal.paste.Source); the location rides alongside in
/// GhosttyPaste and only applies to a clipboard paste.
///
/// C: GhosttyPasteSource
pub const Source = lib.Enum(lib.target, &.{
"clipboard",
"text",
});
/// 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,
mimes: ?[*]const lib.String,
mimes_len: usize,
reader: io.MimeReader,
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_mimes: []const lib.String = if (req.mimes) |ptr|
ptr[0..req.mimes_len]
else
&.{};
// Only a paste with something to read needs a reader.
if (c_mimes.len > 0 and !req.reader.valid()) return .invalid_value;
// 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 mimes = alloc.alloc([]const u8, c_mimes.len) catch return .out_of_memory;
defer alloc.free(mimes);
for (mimes, c_mimes) |*mime, c_mime| mime.* = c_mime.ptr[0..c_mime.len];
const written = wrapper.stream.handler.paste(.{
.source = switch (req.source) {
.clipboard => .{ .clipboard = req.location },
.text => .text,
},
.contents = .{ .reader = .{
.mimes = mimes,
.read = .{ .ctx = @constCast(req), .read_fn = &readTrampoline },
} },
.allow_unsafe = req.allow_unsafe,
}) catch |err| return switch (err) {
error.UnsafePaste => .rejected,
error.NoWritePty => .invalid_value,
error.OutOfMemory => .out_of_memory,
error.ReadFailed,
error.EntropyUnavailable,
error.Canceled,
=> .io_error,
};
if (out_written) |ptr| ptr.* = written;
return .success;
}
/// The sink handed to the C read callback: a GhosttyWriter over the
/// Zig sink, remembering whether the sink itself failed so that can be
/// told apart from the callback failing to read.
const Sink = struct {
writer: *std.Io.Writer,
write_failed: bool = false,
fn write(
userdata: ?*anyopaque,
data: [*]const u8,
len: usize,
) callconv(lib.calling_conv) bool {
const self: *Sink = @ptrCast(@alignCast(userdata.?));
self.writer.writeAll(data[0..len]) catch {
self.write_failed = true;
return false;
};
return true;
}
};
fn readTrampoline(
ctx: ?*anyopaque,
mime: []const u8,
writer: *std.Io.Writer,
) clipboard.MimeReader.Error!void {
const req: *const Request = @ptrCast(@alignCast(ctx.?));
var sink: Sink = .{ .writer = writer };
if (!req.reader.read.?(req.reader.userdata, .init(mime), .{
.write = &Sink.write,
.userdata = &sink,
})) {
return if (sink.write_failed) error.WriteFailed else error.ReadFailed;
}
}
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 +249,273 @@ 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];
}
/// The representations a test paste serves: the MIME list for the
/// request and the data the read callback streams, in pieces.
const Contents = struct {
mimes: [2]lib.String = undefined,
data: [2][]const u8 = undefined,
len: usize = 0,
reads: [2]usize = @splat(0),
fail: bool = false,
fn init(entries: []const struct { []const u8, []const u8 }) Contents {
var self: Contents = .{};
for (entries) |entry| {
self.mimes[self.len] = .init(entry[0]);
self.data[self.len] = entry[1];
self.len += 1;
}
return self;
}
fn request(self: *Contents) Request {
return .{
.location = .standard,
.source = .clipboard,
.mimes = &self.mimes,
.mimes_len = self.len,
.reader = .{ .read = &read, .userdata = self },
.allow_unsafe = false,
};
}
fn read(userdata: ?*anyopaque, mime: lib.String, writer: io.Writer) callconv(lib.calling_conv) bool {
const self: *Contents = @ptrCast(@alignCast(userdata.?));
// The mime is the exact string from the request's list, so
// identifying it by pointer works.
const index: usize = for (self.mimes[0..self.len], 0..) |m, i| {
if (m.ptr == mime.ptr and m.len == mime.len) break i;
} else return false;
self.reads[index] += 1;
if (self.fail) return false;
const data = self.data[index];
var offset: usize = 0;
while (offset < data.len) {
const n = @min(3, data.len - offset);
if (!writer.write.?(writer.userdata, data[offset..].ptr, n)) return false;
offset += n;
}
return true;
}
};
};
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);
try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty)));
var written: bool = true;
var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }});
const req = contents.request();
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));
// MIME types without a reader are rejected; none at all is fine.
var unreadable = req;
unreadable.reader.read = null;
try testing.expectEqual(Result.invalid_value, terminal_paste(t, &unreadable, &written));
unreadable.mimes = null;
unreadable.mimes_len = 0;
try testing.expectEqual(Result.success, terminal_paste(t, &unreadable, &written));
try testing.expect(!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 contents: S.Contents = .init(&.{.{ "text/plain", "hello" }});
const req = contents.request();
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. The text is read
// once, the image never.
var contents: S.Contents = .init(&.{
.{ "image/png", "\x89PNG" },
.{ "text/plain", "hel\x1blo" },
});
const req = contents.request();
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);
try testing.expectEqual(@as(usize, 0), contents.reads[0]);
try testing.expectEqual(@as(usize, 1), contents.reads[1]);
// Unsafe is refused with nothing written, then allowed.
S.reset();
var written: bool = false;
var unsafe_contents: S.Contents = .init(&.{.{ "text/plain", "rm -rf /\n" }});
var unsafe = unsafe_contents.request();
try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expect(!written);
try testing.expectEqual(@as(usize, 1), unsafe_contents.reads[0]);
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());
try testing.expectEqual(@as(usize, 2), unsafe_contents.reads[0]);
// 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 and reads nothing.
S.reset();
var image: S.Contents = .init(&.{.{ "image/png", "\x89PNG" }});
const image_req = image.request();
try testing.expectEqual(Result.success, terminal_paste(t, &image_req, &written));
try testing.expect(!written);
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), image.reads[0]);
// A failing reader is an I/O error.
S.reset();
contents.fail = true;
try testing.expectEqual(Result.io_error, terminal_paste(t, &req, &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;
var contents: S.Contents = .init(&.{
.{ "text/plain", "secret" },
.{ "image/png", "\x89PNG" },
});
var req = contents.request();
req.location = .primary;
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 no data
// is read, let alone written.
S.reset();
contents.reads = @splat(0);
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);
try testing.expectEqual(@as(usize, 0), contents.reads[0]);
try testing.expectEqual(@as(usize, 0), contents.reads[1]);
// 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());
}

View File

@@ -7,4 +7,5 @@ pub const Result = enum(c_int) {
no_value = -4,
io_error = -5,
limit_exceeded = -6,
rejected = -7,
};

View File

@@ -23,6 +23,13 @@ pub const DecodePngFn = *const fn (
*Image,
) callconv(lib.calling_conv) bool;
/// C: GhosttySysRandomSecureFn
pub const RandomSecureFn = *const fn (
?*anyopaque,
[*]u8,
usize,
) callconv(lib.calling_conv) bool;
/// C: GhosttySysLogLevel
pub const LogLevel = enum(c_int) {
@"error" = 0,
@@ -55,12 +62,14 @@ pub const Option = enum(c_int) {
userdata = 0,
decode_png = 1,
log = 2,
random_secure = 3,
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.decode_png => ?DecodePngFn,
.log => ?LogFn,
.random_secure => ?RandomSecureFn,
};
}
};
@@ -71,6 +80,7 @@ const Global = struct {
userdata: ?*anyopaque = null,
decode_png: ?DecodePngFn = null,
log: ?LogFn = null,
random_secure: ?RandomSecureFn = null,
};
/// Global state for the C sys interface.
@@ -98,6 +108,12 @@ fn decodePngWrapper(
};
}
/// Zig-compatible wrapper that calls through to the stored C callback.
fn randomSecureWrapper(buffer: []u8) terminal_sys.RandomSecureError!void {
const func = global.random_secure orelse return error.EntropyUnavailable;
if (!func(global.userdata, buffer.ptr, buffer.len)) return error.EntropyUnavailable;
}
pub fn set(
option: Option,
value: ?*const anyopaque,
@@ -127,6 +143,10 @@ fn setTyped(
terminal_sys.decode_png = if (value != null) &decodePngWrapper else null;
},
.log => global.log = value,
.random_secure => {
global.random_secure = value;
terminal_sys.random_secure = if (value != null) &randomSecureWrapper else null;
},
}
return .success;
}

View File

@@ -353,7 +353,7 @@ const Effects = struct {
};
};
fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void {
fn writePtyTrampoline(handler: *Handler, data: []const u8) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.write_pty orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len);

View File

@@ -37,6 +37,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");
@@ -190,8 +191,10 @@ const type_decls = [_]TypeDecl{
.initStruct("GhosttyFormatterTerminalOptions", formatter.TerminalOptions),
.initStruct("GhosttyGridRef", grid_ref.CGridRef),
.initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo),
.initStruct("GhosttyMimeReader", io.MimeReader),
.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.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_"),

View File

@@ -32,6 +32,43 @@ pub const Content = struct {
data: []const u8,
};
/// Requests content of a specific mime-type. For now this is used for
/// on-demand clipboard access since content can be large (particularly
/// non-text content), but it is generic so that this could handle other
/// mime-typed sources in the future like maybe drag-and-drop.
///
/// C: GhosttyMimeReader
pub const MimeReader = struct {
/// Passed through to `read_fn`.
ctx: ?*anyopaque = null,
/// Write all the data of the representation named by `mime` to
/// `sink`, in as many writes as is convenient. The mime and sink
/// are borrowed, only valid for the duration of the call, and
/// nothing written to the sink is retained, so the data may be
/// borrowed from anywhere. Return error.ReadFailed if the data
/// can't be produced and propagate error.WriteFailed from the
/// sink.
read_fn: *const fn (
ctx: ?*anyopaque,
mime: []const u8,
sink: *std.Io.Writer,
) Error!void,
pub const Error = error{
/// The data could not be read.
ReadFailed,
} || std.Io.Writer.Error;
pub fn read(
self: MimeReader,
mime: []const u8,
sink: *std.Io.Writer,
) Error!void {
return self.read_fn(self.ctx, mime, sink);
}
};
/// One atomic clipboard write.
///
/// Contents are borrowed and only valid for the duration of a clipboard write

View File

@@ -57,8 +57,10 @@ pub const max_write_aliases = write.max_write_aliases;
pub const Response = response.Response;
pub const ReadSuccess = response.ReadSuccess;
pub const PasteEvent = response.PasteEvent;
pub const read_chunk_size = response.read_chunk_size;
pub const max_read_mimes = response.max_read_mimes;
pub const max_listing_mimes = response.max_listing_mimes;
pub const targets_mime = response.targets_mime;
pub const Grants = grants.Grants;

View File

@@ -5,6 +5,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const clipboard_command = @import("clipboard_command.zig");
const sys = @import("../sys.zig");
const max_pw_len = clipboard_command.max_pw_len;
@@ -105,13 +106,31 @@ pub const Grants = struct {
/// The length of a one-time password generated for paste events.
pub const otp_len = 22;
/// Generate a one-time password for a paste event. The alphabet matches
/// kitty (alphanumeric without easily-confused characters), but the spec
/// doesn't demand this.
pub fn generateOtp(random: std.Random) [otp_len]u8 {
const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
/// The one-time password alphabet. This matches kitty (alphanumeric
/// without easily-confused characters), but the spec doesn't demand
/// this.
pub const otp_alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
/// Generate a one-time password for a paste event.
///
/// The password is a secret: a program that learns it can read the
/// clipboard without a prompt. Entropy comes from `sys.random_secure`
/// if set, otherwise from the Io; see `sys.randomSecure`.
pub fn generateOtp(io: std.Io) std.Io.RandomSecureError![otp_len]u8 {
var result: [otp_len]u8 = undefined;
for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)];
var len: usize = 0;
while (len < result.len) {
var raw: [2 * otp_len]u8 = undefined;
try sys.randomSecure(io, &raw);
const limit = (std.math.maxInt(u8) + 1) / otp_alphabet.len * otp_alphabet.len;
for (raw) |byte| {
if (byte >= limit) continue;
result[len] = otp_alphabet[byte % otp_alphabet.len];
len += 1;
if (len == result.len) break;
}
}
return result;
}
@@ -182,3 +201,38 @@ test "grants: capacity evicts the oldest" {
const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries});
try testing.expect(grants.use(alloc, newest, .read));
}
test "generateOtp: length and alphabet with a real Io" {
const testing = std.testing;
const otp = try generateOtp(testing.io);
try testing.expectEqual(otp_len, otp.len);
for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null);
// Two passwords don't collide (a repeat would mean no entropy).
const other = try generateOtp(testing.io);
try testing.expect(!std.mem.eql(u8, &otp, &other));
}
test "generateOtp: no entropy is an error, never a weak password" {
const testing = std.testing;
try testing.expectError(error.EntropyUnavailable, generateOtp(std.Io.failing));
}
test "generateOtp: sys override supplies entropy without an Io source" {
const testing = std.testing;
const S = struct {
var counter: u8 = 0;
fn fill(buffer: []u8) sys.RandomSecureError!void {
for (buffer) |*b| {
b.* = counter;
counter +%= 1;
}
}
};
sys.random_secure = &S.fill;
defer sys.random_secure = null;
const otp = try generateOtp(std.Io.failing);
for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null);
}

View File

@@ -23,6 +23,9 @@ pub const max_read_mimes = 4;
/// The special MIME type that requests the list of available types.
pub const targets_mime = ".";
/// Maximum MIME types reported in a paste event's targets listing.
pub const max_listing_mimes = 16;
/// A single response packet.
pub const Response = struct {
op: Operation,
@@ -182,6 +185,36 @@ pub const ReadSuccess = struct {
}
};
/// An unsolicited Kitty paste event (mode 5522): a read response that
/// lists the clipboard's available MIME types and carries the one-time
/// password the program uses for its follow-up read.
pub const PasteEvent = struct {
/// True if the paste came from the primary selection, reported as
/// `loc=primary` on the OK packet.
primary: bool = false,
/// The one-time password, echoed in every packet.
pw: []const u8,
/// The MIME types available on the clipboard.
available: []const []const u8,
terminator: Terminator = .st,
pub fn encode(
self: *const PasteEvent,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
try (ReadSuccess{
.primary = self.primary,
.pw = self.pw,
.list = true,
.available = self.available,
.terminator = self.terminator,
}).encode(writer);
}
};
test "response: basic status packet" {
const testing = std.testing;
@@ -383,3 +416,54 @@ test "read success: paste event carries pw in every packet" {
writer.buffered(),
);
}
test "paste event: pw in every packet, listing of every type" {
const testing = std.testing;
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (PasteEvent{
.pw = "otp",
.available = &.{ "text/plain", "image/png" },
}).encode(&writer);
// Payload is base64 of "text/plain image/png\n".
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\",
writer.buffered(),
);
}
test "paste event: primary is reported only on the OK packet" {
const testing = std.testing;
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (PasteEvent{
.primary = true,
.pw = "otp",
.available = &.{"text/plain"},
.terminator = .bel,
}).encode(&writer);
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK:loc=primary:pw=b3Rw\x07" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x07" ++
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x07",
writer.buffered(),
);
}
test "paste event: empty listing packet is still sent" {
const testing = std.testing;
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (PasteEvent{ .pw = "otp", .available = &.{} }).encode(&writer);
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw\x1b\\" ++
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\",
writer.buffered(),
);
}

View File

@@ -20,6 +20,7 @@ pub const kitty = @import("kitty.zig");
pub const modes = @import("modes.zig");
pub const page = @import("page.zig");
pub const parse_table = @import("parse_table.zig");
pub const paste = @import("paste.zig");
pub const search = @import("search.zig");
pub const snapshot = @import("snapshot/main.zig");
pub const sgr = @import("sgr.zig");
@@ -60,6 +61,11 @@ pub const TerminalStream = stream_terminal.Stream;
pub const Stream = stream.Stream;
pub const StreamAction = stream.Action;
pub const UnknownSequence = stream_terminal.Handler.UnknownSequence;
pub const Paste = paste.Request;
pub const PasteContents = paste.Contents;
pub const PasteSource = paste.Source;
pub const MimeReader = clipboard.MimeReader;
pub const PasteError = stream_terminal.Handler.PasteError;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = ansi.CursorStyle;

View File

@@ -8,6 +8,7 @@
//! to ensure all our various types and logic remain in sync.
const std = @import("std");
const build_options = @import("terminal_options");
const testing = std.testing;
/// A struct that maintains the state of all the settable modes.
@@ -331,10 +332,12 @@ const entries: []const ModeEntry = &.{
// paste sends an unsolicited OSC 5522 targets listing with a
// one-time password instead of pasting the text.
// See https://sw.kovidgoyal.net/kitty/clipboard/
//
// Forcibly disabled for now since the functionality isn't exposed
// yet to libghostty or any GUI apps.
.{ .name = "kitty_paste_events", .value = 5522, .disabled = true },
.{
.name = "kitty_paste_events",
.value = 5522,
// Only libghostty-vt supports this currently
.disabled = build_options.artifact != .lib,
},
};
test {

247
src/terminal/paste.zig Normal file
View File

@@ -0,0 +1,247 @@
//! Pasting into a terminal.
//!
//! This is the single place that turns "the user pasted" into bytes for
//! the pty, applying the terminal's current state:
//!
//! * Mode 5522 (Kitty clipboard protocol paste events) set, a
//! user-initiated clipboard paste, and the embedder able to serve
//! the program's follow-up clipboard read: send a paste event
//! listing the clipboard's MIME types with a fresh one-time password
//! and record a one-time read grant for it. No data is read.
//! * Otherwise: write the first text representation, with unsafe bytes
//! replaced (xterm behavior), framed per mode 2004 (bracketed paste)
//! or with newlines converted to carriage returns if not.
//!
//! The precedence (5522 event, else 2004 framing, else plain) and the
//! safety rule live only here so every embedder of the terminal shares
//! one implementation.
const std = @import("std");
const Allocator = std.mem.Allocator;
const clipboard = @import("clipboard.zig");
const kitty_clipboard = @import("kitty/clipboard.zig");
const input_paste = @import("../input/paste.zig");
const Terminal = @import("Terminal.zig");
/// Why a paste happened. Only clipboard pastes may become paste
/// events, which is why only they carry a location: it's meaningless
/// for text insertion.
///
/// C: GhosttyPasteSource, flattened next to the location since C has
/// no tagged unions.
pub const Source = union(enum) {
/// The user pasted from a clipboard: keybind, menu, middle click.
/// The payload is the clipboard the contents came from.
clipboard: clipboard.Location,
/// Text inserted some other way: IME commit, drag and drop,
/// scripted input.
text,
};
/// A paste of clipboard contents into the terminal. What actually gets
/// written depends on terminal state; see `paste`.
pub const Request = struct {
/// Why this paste happened, and for a clipboard paste, from which
/// clipboard. Only a user-initiated clipboard paste may become a
/// paste event; text insertion always writes text.
source: Source = .{ .clipboard = .standard },
/// The representations available. Borrowed only during the
/// duration of the paste function call.
contents: Contents,
/// Write data that could inject commands (see `paste`). The usual
/// flow is to call with false, confirm with the user on
/// error.UnsafePaste, and call again with true.
allow_unsafe: bool = false,
};
/// The representations available for a paste, in the embedder's
/// preferred order.
pub const Contents = union(enum) {
/// Every representation already in memory. For text the embedder
/// holds anyway (an IME commit, dropped text) or small clipboards.
memory: []const clipboard.Content,
/// Representations read on demand, so nothing is loaded that isn't
/// pasted. This is the form for a real clipboard, whose non-text
/// items may be huge.
reader: Reader,
/// The on-demand form: the MIME types available plus the reader
/// that produces the data of any one of them.
pub const Reader = struct {
/// The MIME types available, in preferred order.
mimes: []const []const u8,
/// Produces the data of any entry of `mimes`, which is passed
/// through to it as is. A paste reads at most once: the text
/// representation being pasted, never anything else and never
/// anything for a paste event. There is no requirement across
/// paste calls, so a source that changes between an unsafe
/// refusal and the embedder's confirmed retry simply pastes
/// its current contents.
read: clipboard.MimeReader,
};
/// The number of representations.
pub fn len(self: Contents) usize {
return switch (self) {
.memory => |v| v.len,
.reader => |v| v.mimes.len,
};
}
/// The MIME type of representation `index`.
pub fn mime(self: Contents, index: usize) []const u8 {
return switch (self) {
.memory => |v| v[index].mime,
.reader => |v| v.mimes[index],
};
}
};
/// What a caller supplies to `paste`: the terminal state the decision
/// depends on, the session state an event records into, and the sink.
pub const Context = struct {
/// The terminal whose modes decide the encoding.
terminal: *const Terminal,
/// Allocator for transient state: the buffered read and a paste
/// event's grant. Must be the allocator `kitty_clipboard.grants`
/// is freed with.
alloc: Allocator,
/// Receives the encoded text in chunks, or the event. Must be
/// buffered (the encoder works in its buffer) and is not flushed
/// by `paste`; the caller flushes once it returns.
writer: *std.Io.Writer,
/// Kitty clipboard protocol session state, or null if the embedder
/// does not serve Kitty clipboard reads (`clipboard.Read`). A
/// paste event is that protocol: it is only useful if the
/// program's follow-up read can be answered, since otherwise the
/// read would be refused and the user's paste would vanish. With
/// null, `paste` always writes text.
kitty_clipboard: ?KittyClipboard,
pub const KittyClipboard = struct {
/// Session grants. A paste event records its one-time password
/// here so the program's follow-up read is served without a
/// prompt.
grants: *kitty_clipboard.Grants,
/// Secure entropy for one-time passwords. See generateOtp for
/// why there is no fallback when this has none.
io: std.Io,
};
};
pub const Error = Allocator.Error ||
std.Io.RandomSecureError ||
clipboard.MimeReader.Error ||
error{
/// The data could inject commands and allow_unsafe was false.
UnsafePaste,
};
/// Paste into the terminal, applying the terminal's current state as
/// described in the module docs. Returns true if anything was written
/// to `ctx.writer`: the encoded text or a paste event. False means
/// there was nothing to paste (no non-empty text representation).
///
/// The safety rule for a text paste (`input.paste.isSafeWith`): a
/// bracketed paste is unsafe only if it contains the bracket terminator
/// (CSI 201~); an unbracketed paste is unsafe if it contains a newline
/// or the terminator. Embedders wanting a stricter rule check
/// `input.paste.isSafe` themselves before calling. A paste event never
/// puts the data on the input stream, so the rule doesn't apply to it.
///
/// The contents are read at most once per call and buffered whole, so
/// the source needs no stability across reads. Nothing reaches the
/// writer until the read completed and the text passed the rule:
/// every error writes nothing, and a failed event records no grant.
pub fn paste(ctx: Context, req: Request) Error!bool {
// If the source is a clipboard and mode 5522 is enabled and
// the caller can handle kitty events, then do a kitty event.
if (req.source == .clipboard and
ctx.terminal.modes.get(.kitty_paste_events))
{
if (ctx.kitty_clipboard) |kitty| {
try pasteKittyEvent(ctx, kitty, req);
return true;
}
}
// For non-Kitty paste events we can only accept text content.
const index: usize = for (0..req.contents.len()) |i| {
if (clipboard.isTextMime(req.contents.mime(i))) break i;
} else return false;
const opts: input_paste.Options = .fromTerminal(ctx.terminal);
// In-memory contents are used directly to avoid a double copy but
// reader-based contents are read into memory so we can do the unsafe
// scan.
var aw: std.Io.Writer.Allocating = .init(ctx.alloc);
defer aw.deinit();
const text: []const u8 = switch (req.contents) {
.memory => |v| v[index].data,
.reader => |v| text: {
v.read.read(v.mimes[index], &aw.writer) catch |err| switch (err) {
// An allocating writer only fails to allocate.
error.WriteFailed => return error.OutOfMemory,
error.ReadFailed => |e| return e,
};
break :text aw.writer.buffered();
},
};
if (text.len == 0) return false;
if (!req.allow_unsafe and !input_paste.isSafeWith(
text,
opts,
)) return error.UnsafePaste;
// The text is copied exactly once per chunk, into the writer,
// where the encoder strips and converts it in place.
try input_paste.encodeWriter(ctx.writer, text, opts);
return true;
}
fn pasteKittyEvent(
ctx: Context,
kitty: Context.KittyClipboard,
req: Request,
) Error!void {
const otp = try kitty_clipboard.generateOtp(kitty.io);
// Every representation is listed, never read. The listing is
// bounded; a clipboard with more types than that is not a thing.
var mimes_buf: [kitty_clipboard.max_listing_mimes][]const u8 = undefined;
const mimes_len = @min(req.contents.len(), mimes_buf.len);
for (mimes_buf[0..mimes_len], 0..) |*mime, i| mime.* = req.contents.mime(i);
// The grant is recorded before the event can reach the program,
// and revoked if the event fails to be written so a failure never
// leaves a grant for an event that was never sent. Using a
// one-time grant consumes it, which is the revocation.
try kitty.grants.grant(ctx.alloc, &otp, .read, true);
errdefer _ = kitty.grants.use(ctx.alloc, &otp, .read);
try (kitty_clipboard.PasteEvent{
// The protocol only distinguishes the clipboard from the
// primary selection, so both non-standard locations report as
// primary. Only a clipboard paste gets here; see `paste`.
.primary = req.source.clipboard != .standard,
.pw = &otp,
.available = mimes_buf[0..mimes_len],
}).encode(ctx.writer);
}
test {
// The behavior is tested end to end through the stream handler
// (stream_terminal.zig), which is the primary caller.
std.testing.refAllDecls(@This());
}

File diff suppressed because it is too large Load Diff

View File

@@ -52,3 +52,51 @@ fn decodePngWuffs(
.data = result.data,
};
}
/// Fill a buffer with cryptographically secure random bytes. If null,
/// the terminal's `std.Io` (`randomSecure`) is used. This is an override
/// for embedders whose Io has no entropy source (e.g. wasm32-freestanding,
/// where TinyIo degrades to `std.Io.failing`) or that want to control
/// the source; when set it is used on every target.
///
/// This is used for secrets, so it must be a real CSPRNG. An error
/// makes the operation that needed the entropy fail; nothing falls back
/// to weaker randomness.
pub var random_secure: ?RandomSecureFn = null;
pub const RandomSecureError = error{EntropyUnavailable};
pub const RandomSecureFn = *const fn ([]u8) RandomSecureError!void;
/// Fill `buffer` with secure random bytes from `random_secure` if set,
/// otherwise from `io`. Every use of secure entropy in the terminal
/// package goes through this so the override applies uniformly.
pub fn randomSecure(io: std.Io, buffer: []u8) std.Io.RandomSecureError!void {
if (random_secure) |func| return func(buffer);
return io.randomSecure(buffer);
}
test "randomSecure: override is preferred over the Io" {
const testing = std.testing;
const S = struct {
fn fill(buffer: []u8) RandomSecureError!void {
@memset(buffer, 0xAB);
}
fn fail(_: []u8) RandomSecureError!void {
return error.EntropyUnavailable;
}
};
// Without the override a failing Io fails.
var buf: [8]u8 = @splat(0);
try testing.expectError(error.EntropyUnavailable, randomSecure(std.Io.failing, &buf));
// With it, the Io is never consulted.
random_secure = &S.fill;
defer random_secure = null;
try randomSecure(std.Io.failing, &buf);
try testing.expect(std.mem.allEqual(u8, &buf, 0xAB));
// An override failure surfaces as the Io's error.
random_secure = &S.fail;
try testing.expectError(error.EntropyUnavailable, randomSecure(testing.io, &buf));
}