mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
libghostty: paste reads clipboard contents on demand, streams to pty
Follow up to #13978 `ghostty_terminal_paste` no longer takes the clipboard's data up front. The request now carries only the list of available MIME types plus a a callback that writes one representation's bytes into a `GhosttyWriter`. Previously an embedder had to load every representation for every MIME type into memory before pasting. For a clipboard holding a large image or video next to some text that could be hundreds of megabytes that were never used. I also took care to make sure that the data is only read once, to avoid any time-of-check/time-of-use (TOCTOU) issues. There is only one case where data might be fully buffered in memory now: unsafe text data that needs to be checked. This is true for how Ghostty GUI works today too.
This commit is contained in:
@@ -4,8 +4,10 @@ This contains a simple example of how to paste into a `ghostty-vt`
|
||||
terminal with `ghostty_terminal_paste`: plain and bracketed (mode 2004)
|
||||
text pastes, the unsafe-paste confirmation flow, and Kitty clipboard
|
||||
protocol paste events (mode 5522) including the program's follow-up
|
||||
clipboard read. It also shows the terminal-free building blocks for
|
||||
checking paste safety and encoding paste data.
|
||||
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
|
||||
|
||||
@@ -85,23 +85,52 @@ static bool confirm_with_user(void) {
|
||||
}
|
||||
|
||||
//! [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) {
|
||||
GhosttyClipboardContent contents[] = {
|
||||
clipboard_t clipboard = {.text = text};
|
||||
GhosttyString mimes[] = {
|
||||
// The first text representation is what a text paste writes.
|
||||
{.mime = GS("text/plain"),
|
||||
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)}},
|
||||
// Listed on a paste event, never written, so no data is needed.
|
||||
{.mime = GS("image/png"), .data = {.ptr = NULL, .len = 0}},
|
||||
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,
|
||||
.contents = contents,
|
||||
.contents_len = sizeof(contents) / sizeof(contents[0]),
|
||||
.mimes = mimes,
|
||||
.mimes_len = sizeof(mimes) / sizeof(mimes[0]),
|
||||
.reader = {.read = read_clipboard, .userdata = &clipboard},
|
||||
.allow_unsafe = false,
|
||||
};
|
||||
|
||||
@@ -120,7 +149,8 @@ static void paste_clipboard(GhosttyTerminal terminal, const char* text) {
|
||||
}
|
||||
|
||||
// Whether the pty got the text or a paste event depends on the
|
||||
// terminal's modes; either way it went through write_pty above.
|
||||
// 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]
|
||||
@@ -211,17 +241,15 @@ int main() {
|
||||
// Text inserted by other means (IME, drag and drop) is never an event.
|
||||
printf("IME text with mode 5522 enabled:\n");
|
||||
{
|
||||
const char* text = "committed";
|
||||
GhosttyClipboardContent content = {
|
||||
.mime = GS("text/plain"),
|
||||
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
|
||||
};
|
||||
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,
|
||||
.contents = &content,
|
||||
.contents_len = 1,
|
||||
.mimes = &mime,
|
||||
.mimes_len = 1,
|
||||
.reader = {.read = read_clipboard, .userdata = &clipboard},
|
||||
.allow_unsafe = true,
|
||||
};
|
||||
bool written = false;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
*
|
||||
* What a paste writes to the pty depends on the terminal's state, so
|
||||
* the recommended way to paste is ghostty_terminal_paste(). The embedder
|
||||
* hands over what the clipboard holds as MIME-typed contents (just
|
||||
* `text/plain` for an ordinary paste) and where it came from, and the
|
||||
* terminal decides how its current modes apply:
|
||||
* 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:
|
||||
*
|
||||
* - If Kitty clipboard protocol paste events (mode 5522,
|
||||
* GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated
|
||||
@@ -27,20 +28,29 @@
|
||||
* 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.
|
||||
* 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 unless GhosttyPaste::allow_unsafe is set. The usual
|
||||
* flow is to call once, confirm with the user on GHOSTTY_REJECTED, and
|
||||
* call again with `allow_unsafe` set.
|
||||
*
|
||||
* Output is delivered through the write_pty callback
|
||||
* (GHOSTTY_TERMINAL_OPT_WRITE_PTY) in a single call.
|
||||
* 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
|
||||
*
|
||||
@@ -66,6 +76,7 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <ghostty/vt/types.h>
|
||||
#include <ghostty/vt/io.h>
|
||||
#include <ghostty/vt/terminal.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
@@ -73,7 +84,7 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Why a paste happened.
|
||||
* Why a paste happened.
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** The user pasted from a clipboard: keybind, menu, middle click. */
|
||||
@@ -93,8 +104,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* A paste of clipboard contents into the terminal.
|
||||
*
|
||||
* This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The
|
||||
* contents array and the strings it points to are borrowed only for the
|
||||
* duration of the ghostty_terminal_paste() call.
|
||||
* 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. */
|
||||
@@ -112,16 +124,30 @@ typedef struct {
|
||||
GhosttyPasteSource source;
|
||||
|
||||
/**
|
||||
* Borrowed array of the representations available, in preferred
|
||||
* order. A text paste writes the first entry with a text MIME type
|
||||
* such as "text/plain" and ignores the rest. A paste event lists every
|
||||
* entry's MIME type and never reads data, so non-text entries may have
|
||||
* empty data. May be NULL when contents_len is zero.
|
||||
* 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 GhosttyClipboardContent* contents;
|
||||
const GhosttyString* mimes;
|
||||
|
||||
/** Number of entries in contents. */
|
||||
size_t contents_len;
|
||||
/** 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
|
||||
@@ -135,8 +161,12 @@ typedef struct {
|
||||
* clipboard protocol paste event if mode 5522 is enabled and a
|
||||
* clipboard_read callback is installed, otherwise the text framed per
|
||||
* mode 2004. See the group documentation for the full behavior. Output
|
||||
* goes through the write_pty callback in a single call. The viewport is
|
||||
* not scrolled; that is up to the embedder, as for key input.
|
||||
* 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
|
||||
@@ -147,12 +177,12 @@ typedef struct {
|
||||
* @return GHOSTTY_SUCCESS on success (see @p out_written);
|
||||
* GHOSTTY_REJECTED if the text could inject commands and
|
||||
* GhosttyPaste::allow_unsafe is false (nothing was written);
|
||||
* GHOSTTY_INVALID_VALUE for a NULL terminal or paste, or when no
|
||||
* write_pty callback is installed; GHOSTTY_OUT_OF_MEMORY;
|
||||
* GHOSTTY_IO_ERROR if there is no secure entropy source to mint
|
||||
* a paste event password (wasm32-freestanding without
|
||||
* GHOSTTY_SYS_OPT_RANDOM_SECURE set), in which case nothing was
|
||||
* written and no grant was recorded.
|
||||
* GHOSTTY_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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -10,10 +11,15 @@ const ClipboardRead = terminal_c.ClipboardRead;
|
||||
const ClipboardReadReply = terminal_c.ClipboardReadReply;
|
||||
const Result = @import("result.zig").Result;
|
||||
|
||||
/// Why a paste happened.
|
||||
/// 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 = terminal_paste_pkg.Source;
|
||||
pub const Source = lib.Enum(lib.target, &.{
|
||||
"clipboard",
|
||||
"text",
|
||||
});
|
||||
|
||||
/// A paste of clipboard contents into the terminal. Sized struct.
|
||||
///
|
||||
@@ -22,8 +28,9 @@ pub const Request = extern struct {
|
||||
size: usize = @sizeOf(Request),
|
||||
location: clipboard.Location,
|
||||
source: Source,
|
||||
contents: ?[*]const ClipboardContent,
|
||||
contents_len: usize,
|
||||
mimes: ?[*]const lib.String,
|
||||
mimes_len: usize,
|
||||
reader: io.MimeReader,
|
||||
allow_unsafe: bool,
|
||||
};
|
||||
|
||||
@@ -43,42 +50,81 @@ pub fn terminal_paste(
|
||||
// a C callback, so the "nothing can be written" check is ours.
|
||||
if (wrapper.effects.write_pty == null) return .invalid_value;
|
||||
|
||||
const c_contents: []const ClipboardContent = if (req.contents) |ptr|
|
||||
ptr[0..req.contents_len]
|
||||
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 contents = alloc.alloc(
|
||||
clipboard.Content,
|
||||
c_contents.len,
|
||||
) catch return .out_of_memory;
|
||||
defer alloc.free(contents);
|
||||
for (contents, c_contents) |*content, c_content| {
|
||||
content.* = .{
|
||||
.mime = c_content.mime.ptr[0..c_content.mime.len],
|
||||
.data = c_content.data.ptr[0..c_content.data.len],
|
||||
};
|
||||
}
|
||||
const 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(.{
|
||||
.location = req.location,
|
||||
.source = req.source,
|
||||
.contents = contents,
|
||||
.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.EntropyUnavailable, error.Canceled => .io_error,
|
||||
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);
|
||||
@@ -248,21 +294,55 @@ const TerminalPasteCapture = struct {
|
||||
return written[0..written_len];
|
||||
}
|
||||
|
||||
/// A single text/plain request; the caller provides the content
|
||||
/// storage since the request borrows it.
|
||||
fn textRequest(content: *ClipboardContent, text: []const u8) Request {
|
||||
content.* = .{
|
||||
.mime = .init(@as([]const u8, "text/plain")),
|
||||
.data = .init(text),
|
||||
};
|
||||
return .{
|
||||
.location = .standard,
|
||||
.source = .clipboard,
|
||||
.contents = content[0..1],
|
||||
.contents_len = 1,
|
||||
.allow_unsafe = false,
|
||||
};
|
||||
}
|
||||
/// 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" {
|
||||
@@ -272,10 +352,11 @@ test "terminal_paste null handling" {
|
||||
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 content: ClipboardContent = undefined;
|
||||
const req: Request = S.textRequest(&content, "hello");
|
||||
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);
|
||||
@@ -284,6 +365,15 @@ test "terminal_paste null handling" {
|
||||
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" {
|
||||
@@ -295,8 +385,8 @@ test "terminal_paste without write_pty is invalid" {
|
||||
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
var content: ClipboardContent = undefined;
|
||||
const req: Request = S.textRequest(&content, "hello");
|
||||
var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }});
|
||||
const req = contents.request();
|
||||
try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null));
|
||||
}
|
||||
|
||||
@@ -310,26 +400,34 @@ test "terminal_paste text and unsafe" {
|
||||
defer terminal_c.free(t);
|
||||
try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
|
||||
// Plain text, NULL out_written pointer is fine.
|
||||
var content: ClipboardContent = undefined;
|
||||
const req: Request = S.textRequest(&content, "hel\x1blo");
|
||||
// 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_content: ClipboardContent = undefined;
|
||||
var unsafe: Request = S.textRequest(&unsafe_content, "rm -rf /\n");
|
||||
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();
|
||||
@@ -339,16 +437,20 @@ test "terminal_paste text and unsafe" {
|
||||
try testing.expect(written);
|
||||
try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice());
|
||||
|
||||
// No text representation writes nothing. NULL contents with a zero
|
||||
// length is an empty list.
|
||||
// No text representation writes nothing and reads nothing.
|
||||
S.reset();
|
||||
var empty_content: ClipboardContent = undefined;
|
||||
var empty: Request = S.textRequest(&empty_content, "");
|
||||
empty.contents = null;
|
||||
empty.contents_len = 0;
|
||||
try testing.expectEqual(Result.success, terminal_paste(t, &empty, &written));
|
||||
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" {
|
||||
@@ -364,30 +466,20 @@ test "terminal_paste event" {
|
||||
|
||||
// Without a clipboard_read callback the paste stays text.
|
||||
var written: bool = false;
|
||||
const contents = [_]ClipboardContent{
|
||||
.{
|
||||
.mime = .init(@as([]const u8, "text/plain")),
|
||||
.data = .init(@as([]const u8, "secret")),
|
||||
},
|
||||
.{
|
||||
.mime = .init(@as([]const u8, "image/png")),
|
||||
.data = .init(@as([]const u8, "")),
|
||||
},
|
||||
};
|
||||
const req: Request = .{
|
||||
.location = .primary,
|
||||
.source = .clipboard,
|
||||
.contents = &contents,
|
||||
.contents_len = contents.len,
|
||||
.allow_unsafe = false,
|
||||
};
|
||||
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 the data
|
||||
// is never written.
|
||||
// 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);
|
||||
@@ -396,6 +488,8 @@ test "terminal_paste event" {
|
||||
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=";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,6 @@ const formatter_pkg = @import("../formatter.zig");
|
||||
const modes_pkg = @import("../modes.zig");
|
||||
const mouse_pkg = @import("../mouse.zig");
|
||||
const page = @import("../page.zig");
|
||||
const paste_pkg = @import("../paste.zig");
|
||||
const point = @import("../point.zig");
|
||||
const Selection = @import("../Selection.zig");
|
||||
const sgr = @import("../sgr.zig");
|
||||
@@ -192,6 +191,7 @@ 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),
|
||||
@@ -275,7 +275,7 @@ const type_decls = [_]TypeDecl{
|
||||
"GHOSTTY_OSC_COMMAND_",
|
||||
"TYPE_MAX_VALUE",
|
||||
),
|
||||
.initEnum("GhosttyPasteSource", paste_pkg.Source, "GHOSTTY_PASTE_SOURCE_"),
|
||||
.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_"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,7 +62,9 @@ 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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! 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. The data is not written.
|
||||
//! 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.
|
||||
@@ -18,87 +18,134 @@
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const lib = @import("lib.zig");
|
||||
|
||||
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.
|
||||
/// 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
|
||||
pub const Source = lib.Enum(lib.target, &.{
|
||||
// The user pasted from a clipboard: keybind, menu, middle click.
|
||||
"clipboard",
|
||||
/// 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. Never becomes a paste event, matching kitty.
|
||||
// This is not a way to opt out of events; an embedder that
|
||||
// doesn't want them doesn't serve clipboard reads.
|
||||
"text",
|
||||
});
|
||||
/// 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 {
|
||||
/// The clipboard the contents came from. Reported to the program on
|
||||
/// a paste event (`.primary` and `.selection` both as loc=primary,
|
||||
/// the protocol knows only two); no effect on a text paste.
|
||||
location: clipboard.Location = .standard,
|
||||
/// 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 },
|
||||
|
||||
/// Why this paste happened. Only a user-initiated clipboard paste
|
||||
/// may become a paste event; text insertion always writes text.
|
||||
source: Source = .clipboard,
|
||||
/// The representations available. Borrowed only during the
|
||||
/// duration of the paste function call.
|
||||
contents: Contents,
|
||||
|
||||
/// The representations available, in the embedder's preferred
|
||||
/// order. A text paste writes the first representation with a text
|
||||
/// MIME type (clipboard.isTextMime) and ignores the rest. A paste
|
||||
/// event reports every MIME type and never touches data, so
|
||||
/// non-text entries may carry empty data. Borrowed for the call.
|
||||
contents: []const clipboard.Content,
|
||||
|
||||
/// Write data that could inject commands (see `isSafe`). The usual
|
||||
/// 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,
|
||||
|
||||
/// Kitty clipboard 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,
|
||||
|
||||
/// Allocator for the grant. Must be the one `grants` is freed with.
|
||||
/// 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,
|
||||
|
||||
/// True if the embedder serves clipboard reads, so a paste event's
|
||||
/// follow-up read can be answered. Without that an event would be
|
||||
/// refused and the user's paste would vanish, so `paste` falls
|
||||
/// through to a text paste instead.
|
||||
can_event: bool,
|
||||
|
||||
/// Receives the bytes for the pty. `paste` makes exactly one logical
|
||||
/// write per call: the whole encoded text or the whole event. On
|
||||
/// error the writer may hold a partial result that must be
|
||||
/// discarded.
|
||||
/// 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 || std.Io.Writer.Error || error{
|
||||
/// The data could inject commands and allow_unsafe was false.
|
||||
/// Nothing was written.
|
||||
UnsafePaste,
|
||||
};
|
||||
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
|
||||
@@ -112,70 +159,85 @@ pub const Error = Allocator.Error || std.Io.RandomSecureError || std.Io.Writer.E
|
||||
/// `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.
|
||||
///
|
||||
/// On success, the caller delivers the writer's contents to the pty.
|
||||
/// On error nothing should be delivered; in particular an event's grant
|
||||
/// is only recorded once the event is fully encoded, so a failure never
|
||||
/// leaves a grant for an event that was never sent.
|
||||
/// 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 {
|
||||
// A paste event only works if the program's follow-up read can be
|
||||
// served; without that, fall through to text.
|
||||
// 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.can_event and
|
||||
ctx.terminal.modes.get(.kitty_paste_events))
|
||||
{
|
||||
try pasteKittyEvent(ctx, req);
|
||||
return true;
|
||||
if (ctx.kitty_clipboard) |kitty| {
|
||||
try pasteKittyEvent(ctx, kitty, req);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-Kitty paste events we can only accept text content.
|
||||
const text: []const u8 = for (req.contents) |c| {
|
||||
if (clipboard.isTextMime(c.mime)) break c.data;
|
||||
const index: usize = for (0..req.contents.len()) |i| {
|
||||
if (clipboard.isTextMime(req.contents.mime(i))) break i;
|
||||
} else return false;
|
||||
if (text.len == 0) return false;
|
||||
|
||||
// Reject unsafe inputs
|
||||
const opts: input_paste.Options = .fromTerminal(ctx.terminal);
|
||||
if (!req.allow_unsafe and !input_paste.isSafeWith(text, opts)) {
|
||||
return error.UnsafePaste;
|
||||
}
|
||||
|
||||
// The data is copied exactly once, into the writer, where the
|
||||
// encoder strips and converts it in place.
|
||||
// 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, req: Request) Error!void {
|
||||
const otp = try kitty_clipboard.generateOtp(ctx.io);
|
||||
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;
|
||||
var mimes_len: usize = 0;
|
||||
for (req.contents) |c| {
|
||||
if (mimes_len == mimes_buf.len) break;
|
||||
mimes_buf[mimes_len] = c.mime;
|
||||
mimes_len += 1;
|
||||
}
|
||||
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.
|
||||
.primary = req.location != .standard,
|
||||
// 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);
|
||||
|
||||
// Recorded last so a failed encode leaves no grant behind. The
|
||||
// caller delivers the event after we return, so the grant is in
|
||||
// place before the program can possibly use it.
|
||||
try ctx.grants.grant(
|
||||
ctx.alloc,
|
||||
&otp,
|
||||
.read,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
test {
|
||||
|
||||
@@ -108,7 +108,7 @@ pub const Handler = struct {
|
||||
/// e.g. in response to a DECRQM query. The data is only valid
|
||||
/// during the lifetime of the call so callers must copy it
|
||||
/// if it needs to be stored or used after the call returns.
|
||||
write_pty: ?*const fn (*Handler, [:0]const u8) void,
|
||||
write_pty: ?*const fn (*Handler, []const u8) void,
|
||||
|
||||
/// Called when the bell is rung (BEL).
|
||||
bell: ?*const fn (*Handler) void,
|
||||
@@ -300,46 +300,53 @@ pub const Handler = struct {
|
||||
/// Nothing was written.
|
||||
UnsafePaste,
|
||||
|
||||
/// The contents reader failed. Nothing was written.
|
||||
ReadFailed,
|
||||
|
||||
/// No write_pty effect is set, so nothing can be written.
|
||||
NoWritePty,
|
||||
};
|
||||
|
||||
/// The size of the chunks a paste streams to write_pty in.
|
||||
pub const paste_chunk_size = 4096;
|
||||
|
||||
/// Paste into the terminal, applying the terminal's current state
|
||||
/// as necessary to owner mode 5522, bracketed paste, unsafe paste, etc.
|
||||
/// Returns true if anything was written to the pty.
|
||||
///
|
||||
/// The output streams to write_pty in chunks of `paste_chunk_size`.
|
||||
/// The contents are read at most once and only the pasted text
|
||||
/// representation is ever read, buffered whole while it is checked
|
||||
/// and encoded; see `terminal.paste`.
|
||||
pub fn paste(self: *Handler, req: Paste) PasteError!bool {
|
||||
if (self.effects.write_pty == null) return error.NoWritePty;
|
||||
|
||||
// One buffer for the whole result (frame + data + sentinel, or
|
||||
// the event packets). Typical pastes stay on the stack.
|
||||
const alloc = self.terminal.gpa();
|
||||
var stack = std.heap.stackFallback(4096, alloc);
|
||||
const stack_alloc = stack.get();
|
||||
var aw: std.Io.Writer.Allocating = .init(stack_alloc);
|
||||
defer aw.deinit();
|
||||
var buf: [paste_chunk_size]u8 = undefined;
|
||||
var pty: PtyWriter = .init(self, &buf);
|
||||
// Delivered on error too: a partial paste has its frame closed
|
||||
// and the program must see that.
|
||||
defer pty.writer.flush() catch unreachable;
|
||||
|
||||
const written_any = paste_pkg.paste(.{
|
||||
return paste_pkg.paste(.{
|
||||
.terminal = self.terminal,
|
||||
.grants = &self.kitty_clipboard_grants,
|
||||
.io = self.terminal.io(),
|
||||
.alloc = alloc,
|
||||
.can_event = self.effects.clipboard_read != null,
|
||||
.writer = &aw.writer,
|
||||
}, req) catch |err| return switch (err) {
|
||||
// An allocating writer only fails to allocate.
|
||||
error.WriteFailed => error.OutOfMemory,
|
||||
.alloc = self.terminal.gpa(),
|
||||
// Paste events need the program's follow-up Kitty
|
||||
// clipboard read served.
|
||||
.kitty_clipboard = if (self.effects.clipboard_read != null) .{
|
||||
.grants = &self.kitty_clipboard_grants,
|
||||
.io = self.terminal.io(),
|
||||
} else null,
|
||||
.writer = &pty.writer,
|
||||
}, req) catch |err| switch (err) {
|
||||
// The pty writer never fails.
|
||||
error.WriteFailed => unreachable,
|
||||
error.ReadFailed,
|
||||
error.OutOfMemory,
|
||||
error.UnsafePaste,
|
||||
error.EntropyUnavailable,
|
||||
error.Canceled,
|
||||
=> |e| e,
|
||||
};
|
||||
if (!written_any) return false;
|
||||
|
||||
const written = try aw.toOwnedSliceSentinel(0);
|
||||
defer stack_alloc.free(written);
|
||||
self.writePty(written);
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn vt(
|
||||
@@ -529,7 +536,7 @@ pub const Handler = struct {
|
||||
}
|
||||
}
|
||||
|
||||
inline fn writePty(self: *Handler, data: [:0]const u8) void {
|
||||
inline fn writePty(self: *Handler, data: []const u8) void {
|
||||
const func = self.effects.write_pty orelse return;
|
||||
func(self, data);
|
||||
}
|
||||
@@ -1745,6 +1752,51 @@ pub const Handler = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// A writer that delivers everything through the write_pty effect:
|
||||
/// the buffer as it fills, and data that doesn't fit it directly.
|
||||
/// Never fails, since the effect can't.
|
||||
const PtyWriter = struct {
|
||||
handler: *Handler,
|
||||
writer: std.Io.Writer,
|
||||
|
||||
fn init(handler: *Handler, buffer: []u8) PtyWriter {
|
||||
return .{
|
||||
.handler = handler,
|
||||
.writer = .{
|
||||
.vtable = &.{ .drain = drain },
|
||||
.buffer = buffer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn drain(
|
||||
w: *std.Io.Writer,
|
||||
data: []const []const u8,
|
||||
splat: usize,
|
||||
) std.Io.Writer.Error!usize {
|
||||
const self: *PtyWriter = @alignCast(@fieldParentPtr("writer", w));
|
||||
|
||||
// Buffered bytes go first to keep the order.
|
||||
if (w.end > 0) {
|
||||
self.handler.writePty(w.buffer[0..w.end]);
|
||||
w.end = 0;
|
||||
}
|
||||
|
||||
var consumed: usize = 0;
|
||||
for (data[0 .. data.len - 1]) |slice| {
|
||||
if (slice.len > 0) self.handler.writePty(slice);
|
||||
consumed += slice.len;
|
||||
}
|
||||
|
||||
const pattern = data[data.len - 1];
|
||||
for (0..splat) |_| {
|
||||
if (pattern.len > 0) self.handler.writePty(pattern);
|
||||
consumed += pattern.len;
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
};
|
||||
|
||||
test "resize clears synchronized output on unchanged cell dimensions" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
@@ -1817,7 +1869,7 @@ test "resize reports mode 2048 geometry" {
|
||||
var response: [128]u8 = undefined;
|
||||
var response_len: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
}
|
||||
@@ -1846,7 +1898,7 @@ test "resize suppresses mode 2048 reports" {
|
||||
const S = struct {
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, _: []const u8) void {
|
||||
calls += 1;
|
||||
}
|
||||
};
|
||||
@@ -1902,7 +1954,7 @@ test "resize failure preserves terminal state and does not write" {
|
||||
const S = struct {
|
||||
var called: bool = false;
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, _: []const u8) void {
|
||||
called = true;
|
||||
}
|
||||
};
|
||||
@@ -1944,7 +1996,7 @@ test "resize effects do not change canonical terminal state" {
|
||||
defer readonly.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {}
|
||||
fn writePty(_: *Handler, _: []const u8) void {}
|
||||
};
|
||||
var authoritative_handler: Handler = .init(&authoritative);
|
||||
authoritative_handler.effects.write_pty = &S.writePty;
|
||||
@@ -2216,7 +2268,7 @@ test "DECRQSS responses" {
|
||||
calls = 0;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
calls += 1;
|
||||
@@ -2288,7 +2340,7 @@ test "XTGETTCAP responses" {
|
||||
calls = 0;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
calls += 1;
|
||||
@@ -2370,7 +2422,7 @@ test "XTGETTCAP TN responses" {
|
||||
calls = 0;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
calls += 1;
|
||||
@@ -2500,7 +2552,7 @@ test "glyph protocol APC with write_pty callback" {
|
||||
|
||||
const S = struct {
|
||||
var last_response: ?[:0]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (last_response) |old| testing.allocator.free(old);
|
||||
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -2679,7 +2731,7 @@ test "OSC color query responses" {
|
||||
last_response = null;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
reset();
|
||||
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -2830,7 +2882,7 @@ test "kitty color protocol query responses" {
|
||||
last_response = null;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
reset();
|
||||
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -3305,7 +3357,7 @@ test "clipboard_read effect callback" {
|
||||
}} } };
|
||||
var reply_twice: bool = false;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written.appendSlice(testing.allocator, data) catch @panic("OOM");
|
||||
}
|
||||
|
||||
@@ -3480,7 +3532,7 @@ const KittyClipboardCapture = struct {
|
||||
last_read_can_remember = false;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
@memcpy(responses[responses_len..][0..data.len], data);
|
||||
responses_len += data.len;
|
||||
}
|
||||
@@ -4108,7 +4160,7 @@ test "request mode DECRQM with write_pty callback" {
|
||||
{
|
||||
const S = struct {
|
||||
var last_response: ?[:0]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (last_response) |old| testing.allocator.free(old);
|
||||
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4232,7 +4284,7 @@ test "kitty_keyboard_query" {
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
var written_buf: [64]u8 = undefined;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
std.debug.assert(data.len <= written_buf.len);
|
||||
@memcpy(written_buf[0..data.len], data);
|
||||
written = written_buf[0..data.len];
|
||||
@@ -4264,7 +4316,7 @@ test "xtversion default" {
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
var written_buf: [64]u8 = undefined;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
std.debug.assert(data.len <= written_buf.len);
|
||||
@memcpy(written_buf[0..data.len], data);
|
||||
written = written_buf[0..data.len];
|
||||
@@ -4290,7 +4342,7 @@ test "xtversion with effect" {
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
var written_buf: [64]u8 = undefined;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
std.debug.assert(data.len <= written_buf.len);
|
||||
@memcpy(written_buf[0..data.len], data);
|
||||
written = written_buf[0..data.len];
|
||||
@@ -4319,7 +4371,7 @@ test "xtversion with empty string effect" {
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
var written_buf: [64]u8 = undefined;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
std.debug.assert(data.len <= written_buf.len);
|
||||
@memcpy(written_buf[0..data.len], data);
|
||||
written = written_buf[0..data.len];
|
||||
@@ -4348,7 +4400,7 @@ test "size report csi_14_t with effect" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
fn getSize(_: *Handler) ?size_report.Size {
|
||||
@@ -4379,7 +4431,7 @@ test "mode 2048 enable reports current geometry and disable is silent" {
|
||||
var response_len: usize = 0;
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
@memcpy(response[0..data.len], data);
|
||||
response_len = data.len;
|
||||
calls += 1;
|
||||
@@ -4414,7 +4466,7 @@ test "mode 2048 enable tolerates missing effects" {
|
||||
const S = struct {
|
||||
var calls: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, _: []const u8) void {
|
||||
calls += 1;
|
||||
}
|
||||
|
||||
@@ -4467,7 +4519,7 @@ test "size report csi_16_t with effect" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
fn getSize(_: *Handler) ?size_report.Size {
|
||||
@@ -4495,7 +4547,7 @@ test "size report csi_18_t with effect" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
fn getSize(_: *Handler) ?size_report.Size {
|
||||
@@ -4523,7 +4575,7 @@ test "size report no effect callback" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
@@ -4546,7 +4598,7 @@ test "size report csi_21_t title disabled by default" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
@@ -4572,7 +4624,7 @@ test "size report csi_21_t title enabled" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
@@ -4600,7 +4652,7 @@ test "enquiry no effect" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
@@ -4623,7 +4675,7 @@ test "enquiry with effect" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
fn enquiry(_: *Handler) []const u8 {
|
||||
@@ -4650,7 +4702,7 @@ test "enquiry with empty response" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
fn enquiry(_: *Handler) []const u8 {
|
||||
@@ -4677,7 +4729,7 @@ test "device status: operating status" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4702,7 +4754,7 @@ test "device status: cursor position" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4732,7 +4784,7 @@ test "device status: cursor position with origin mode" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4764,7 +4816,7 @@ test "device status: color scheme dark" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4793,7 +4845,7 @@ test "device status: color scheme light" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4822,7 +4874,7 @@ test "device status: color scheme without callback" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4849,7 +4901,7 @@ test "visibility reports" {
|
||||
var written: ?[]const u8 = null;
|
||||
var count: usize = 0;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
count += 1;
|
||||
@@ -4918,7 +4970,7 @@ test "device attributes: primary DA" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4946,7 +4998,7 @@ test "device attributes: secondary DA" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -4974,7 +5026,7 @@ test "device attributes: tertiary DA" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -5021,7 +5073,7 @@ test "device attributes: custom response" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -5063,7 +5115,7 @@ test "kitty graphics APC response" {
|
||||
|
||||
const S = struct {
|
||||
var written: ?[]const u8 = null;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
if (written) |old| testing.allocator.free(old);
|
||||
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
|
||||
}
|
||||
@@ -5120,7 +5172,7 @@ test "continuation reconstructs standard stream without duplicate effects" {
|
||||
title_count += 1;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, _: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, _: []const u8) void {
|
||||
write_count += 1;
|
||||
}
|
||||
|
||||
@@ -5257,7 +5309,7 @@ test "continuation reconstructs standard stream without duplicate effects" {
|
||||
test "kitty dnd: query response" {
|
||||
const S = struct {
|
||||
var pty: std.ArrayListUnmanaged(u8) = .empty;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
pty.appendSlice(testing.allocator, data) catch unreachable;
|
||||
}
|
||||
};
|
||||
@@ -5279,7 +5331,7 @@ test "kitty dnd: query response" {
|
||||
test "kitty dnd: register, drop, and serve data" {
|
||||
const S = struct {
|
||||
var pty: std.ArrayListUnmanaged(u8) = .empty;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
pty.appendSlice(testing.allocator, data) catch unreachable;
|
||||
}
|
||||
};
|
||||
@@ -5355,7 +5407,7 @@ test "kitty dnd: state updates work without write_pty effect" {
|
||||
test "kitty dnd: registration survives terminal reset" {
|
||||
const S = struct {
|
||||
var pty: std.ArrayListUnmanaged(u8) = .empty;
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
pty.appendSlice(testing.allocator, data) catch unreachable;
|
||||
}
|
||||
};
|
||||
@@ -5480,7 +5532,7 @@ const PasteCapture = struct {
|
||||
written = .empty;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
fn writePty(_: *Handler, data: []const u8) void {
|
||||
written.appendSlice(testing.allocator, data) catch @panic("OOM");
|
||||
write_count += 1;
|
||||
}
|
||||
@@ -5509,7 +5561,7 @@ test "paste: no write_pty effect is an error" {
|
||||
var handler: Handler = .init(&t);
|
||||
defer handler.deinit();
|
||||
try testing.expectError(error.NoWritePty, handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5527,7 +5579,7 @@ test "paste: plain text converts newlines and strips unsafe bytes" {
|
||||
|
||||
// Newlines are unsafe unbracketed; the embedder confirmed.
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }} },
|
||||
.allow_unsafe = true,
|
||||
}));
|
||||
try testing.expectEqualStrings("hel lo\rwor ld", S.written.items);
|
||||
@@ -5536,11 +5588,11 @@ test "paste: plain text converts newlines and strips unsafe bytes" {
|
||||
// The first text representation is used; others are ignored.
|
||||
S.reset();
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{
|
||||
.contents = .{ .memory = &.{
|
||||
.{ .mime = "image/png", .data = "\x89PNG" },
|
||||
.{ .mime = "UTF8_STRING", .data = "hi" },
|
||||
.{ .mime = "text/plain", .data = "ignored" },
|
||||
},
|
||||
} },
|
||||
}));
|
||||
try testing.expectEqualStrings("hi", S.written.items);
|
||||
try testing.expectEqual(@as(usize, 1), S.write_count);
|
||||
@@ -5559,12 +5611,12 @@ test "paste: unsafe text is refused unless allowed" {
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
|
||||
try testing.expectError(error.UnsafePaste, handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} },
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} },
|
||||
.allow_unsafe = true,
|
||||
}));
|
||||
try testing.expectEqualStrings("rm -rf /\r", S.written.items);
|
||||
@@ -5585,7 +5637,7 @@ test "paste: bracketed paste frames the text" {
|
||||
|
||||
// Newlines are safe inside the frame and are preserved.
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "hello\nworld" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello\nworld" }} },
|
||||
}));
|
||||
try testing.expectEqualStrings("\x1b[200~hello\nworld\x1b[201~", S.written.items);
|
||||
try testing.expectEqual(@as(usize, 1), S.write_count);
|
||||
@@ -5593,13 +5645,13 @@ test "paste: bracketed paste frames the text" {
|
||||
// The frame terminator is not.
|
||||
S.reset();
|
||||
try testing.expectError(error.UnsafePaste, handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }} },
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
|
||||
// Allowed, the stripper still defuses it: ESC becomes a space.
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }} },
|
||||
.allow_unsafe = true,
|
||||
}));
|
||||
try testing.expectEqualStrings("\x1b[200~he [201~llo\x1b[201~", S.written.items);
|
||||
@@ -5618,18 +5670,18 @@ test "paste: no text representation writes nothing" {
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
|
||||
try testing.expect(!try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "image/png", .data = "\x89PNG" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "image/png", .data = "\x89PNG" }} },
|
||||
}));
|
||||
try testing.expect(!try handler.paste(.{
|
||||
.contents = &.{},
|
||||
.contents = .{ .memory = &.{} },
|
||||
}));
|
||||
try testing.expect(!try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "" }} },
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
}
|
||||
|
||||
test "paste: large text falls back to the heap in one write" {
|
||||
test "paste: large text streams to the pty in chunks" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
@@ -5642,16 +5694,158 @@ test "paste: large text falls back to the heap in one write" {
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
t.modes.set(.bracketed_paste, true);
|
||||
|
||||
// Two full chunks and a partial one with the frame, never the
|
||||
// whole thing at once.
|
||||
const data = "x" ** 10_000;
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = data }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = data }} },
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 1), S.write_count);
|
||||
try testing.expectEqual(data.len + "\x1b[200~\x1b[201~".len, S.written.items.len);
|
||||
const total = data.len + "\x1b[200~\x1b[201~".len;
|
||||
try testing.expectEqual(
|
||||
@as(usize, (total + Handler.paste_chunk_size - 1) / Handler.paste_chunk_size),
|
||||
S.write_count,
|
||||
);
|
||||
try testing.expectEqual(total, S.written.items.len);
|
||||
try testing.expect(std.mem.startsWith(u8, S.written.items, "\x1b[200~xxx"));
|
||||
try testing.expect(std.mem.endsWith(u8, S.written.items, "xxx\x1b[201~"));
|
||||
}
|
||||
|
||||
/// A paste contents reader for the tests: serves fixed data per MIME
|
||||
/// type in pieces, counting the reads of each representation.
|
||||
const PasteReader = struct {
|
||||
mimes: []const []const u8,
|
||||
data: []const []const u8,
|
||||
piece: usize = 3,
|
||||
reads: [4]usize = @splat(0),
|
||||
/// Fail after this many bytes of a read.
|
||||
fail_after: ?usize = null,
|
||||
|
||||
fn contents(self: *PasteReader) paste_pkg.Contents {
|
||||
return .{ .reader = .{
|
||||
.mimes = self.mimes,
|
||||
.read = .{ .ctx = self, .read_fn = &read },
|
||||
} };
|
||||
}
|
||||
|
||||
fn read(ctx: ?*anyopaque, mime: []const u8, sink: *std.Io.Writer) clipboard.MimeReader.Error!void {
|
||||
const self: *PasteReader = @ptrCast(@alignCast(ctx.?));
|
||||
const index: usize = for (self.mimes, 0..) |m, i| {
|
||||
if (std.mem.eql(u8, m, mime)) break i;
|
||||
} else return error.ReadFailed;
|
||||
self.reads[index] += 1;
|
||||
const data = self.data[index];
|
||||
var offset: usize = 0;
|
||||
while (offset < data.len) {
|
||||
if (self.fail_after) |limit| if (offset >= limit) return error.ReadFailed;
|
||||
const n = @min(self.piece, data.len - offset);
|
||||
try sink.writeAll(data[offset..][0..n]);
|
||||
offset += n;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "paste: reader contents are read on demand" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = PasteCapture;
|
||||
S.reset();
|
||||
defer S.deinit();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
defer handler.deinit();
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_read = &S.clipboardRead;
|
||||
|
||||
// Unsafe text is refused from one read with nothing written; the
|
||||
// image is never read.
|
||||
var reader: PasteReader = .{
|
||||
.mimes = &.{ "image/png", "text/plain" },
|
||||
.data = &.{ "\x89PNG", "echo hi\nrm -rf /\n" },
|
||||
};
|
||||
try testing.expectError(error.UnsafePaste, handler.paste(.{
|
||||
.contents = reader.contents(),
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
try testing.expectEqual(@as(usize, 0), reader.reads[0]);
|
||||
try testing.expectEqual(@as(usize, 1), reader.reads[1]);
|
||||
|
||||
// Allowed, the text is read once, encoded.
|
||||
reader.reads = @splat(0);
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = reader.contents(),
|
||||
.allow_unsafe = true,
|
||||
}));
|
||||
try testing.expectEqualStrings("echo hi\rrm -rf /\r", S.written.items);
|
||||
try testing.expectEqual(@as(usize, 0), reader.reads[0]);
|
||||
try testing.expectEqual(@as(usize, 1), reader.reads[1]);
|
||||
|
||||
// Safe text is read once too: buffered for the check, then written.
|
||||
S.reset();
|
||||
reader = .{
|
||||
.mimes = &.{"text/plain"},
|
||||
.data = &.{"hello world"},
|
||||
};
|
||||
try testing.expect(try handler.paste(.{ .contents = reader.contents() }));
|
||||
try testing.expectEqualStrings("hello world", S.written.items);
|
||||
try testing.expectEqual(@as(usize, 1), reader.reads[0]);
|
||||
|
||||
// Empty text is nothing to paste, found on the one read.
|
||||
S.reset();
|
||||
reader = .{
|
||||
.mimes = &.{"text/plain"},
|
||||
.data = &.{""},
|
||||
};
|
||||
try testing.expect(!try handler.paste(.{ .contents = reader.contents() }));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
try testing.expectEqual(@as(usize, 1), reader.reads[0]);
|
||||
|
||||
// A paste event lists the types and reads nothing at all.
|
||||
S.reset();
|
||||
t.modes.set(.kitty_paste_events, true);
|
||||
reader = .{
|
||||
.mimes = &.{ "text/plain", "image/png" },
|
||||
.data = &.{ "secret", "\x89PNG" },
|
||||
};
|
||||
try testing.expect(try handler.paste(.{ .contents = reader.contents() }));
|
||||
try testing.expect(std.mem.indexOf(u8, S.written.items, ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null);
|
||||
try testing.expectEqual(@as(usize, 0), reader.reads[0]);
|
||||
try testing.expectEqual(@as(usize, 0), reader.reads[1]);
|
||||
}
|
||||
|
||||
test "paste: reader failure writes nothing" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = PasteCapture;
|
||||
S.reset();
|
||||
defer S.deinit();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
defer handler.deinit();
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
t.modes.set(.bracketed_paste, true);
|
||||
|
||||
// The read is buffered whole before anything is written, so a
|
||||
// mid-read failure discards the buffer, checked or not.
|
||||
var reader: PasteReader = .{
|
||||
.mimes = &.{"text/plain"},
|
||||
.data = &.{"hello world"},
|
||||
.fail_after = 6,
|
||||
};
|
||||
try testing.expectError(error.ReadFailed, handler.paste(.{
|
||||
.contents = reader.contents(),
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
|
||||
try testing.expectError(error.ReadFailed, handler.paste(.{
|
||||
.contents = reader.contents(),
|
||||
.allow_unsafe = true,
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
}
|
||||
|
||||
test "paste: mode 5522 sends an event the program can read with" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
@@ -5671,10 +5865,10 @@ test "paste: mode 5522 sends an event the program can read with" {
|
||||
// Every representation is listed, the data is never written, and
|
||||
// the one-time password rides on every packet.
|
||||
try testing.expect(try s.handler.paste(.{
|
||||
.contents = &.{
|
||||
.contents = .{ .memory = &.{
|
||||
.{ .mime = "text/plain", .data = "secret" },
|
||||
.{ .mime = "image/png", .data = "" },
|
||||
},
|
||||
} },
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 1), S.write_count);
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.written.items, "\x1b]5522;"));
|
||||
@@ -5735,7 +5929,7 @@ test "paste: mode 5522 sends an event the program can read with" {
|
||||
// Every event mints a fresh password.
|
||||
S.reset();
|
||||
try testing.expect(try s.handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "secret" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} },
|
||||
}));
|
||||
try testing.expect(std.mem.indexOf(u8, S.written.items, pw_b64) == null);
|
||||
}
|
||||
@@ -5757,8 +5951,8 @@ test "paste: mode 5522 reports the selection as primary" {
|
||||
for ([_]clipboard.Location{ .primary, .selection }) |location| {
|
||||
S.reset();
|
||||
try testing.expect(try handler.paste(.{
|
||||
.location = location,
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "x" }},
|
||||
.source = .{ .clipboard = location },
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} },
|
||||
}));
|
||||
try testing.expect(std.mem.startsWith(
|
||||
u8,
|
||||
@@ -5771,8 +5965,8 @@ test "paste: mode 5522 reports the selection as primary" {
|
||||
|
||||
S.reset();
|
||||
try testing.expect(try handler.paste(.{
|
||||
.location = .standard,
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "x" }},
|
||||
.source = .{ .clipboard = .standard },
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} },
|
||||
}));
|
||||
try testing.expect(std.mem.indexOf(u8, S.written.items, "loc=") == null);
|
||||
}
|
||||
@@ -5791,7 +5985,7 @@ test "paste: mode 5522 without clipboard_read pastes text" {
|
||||
t.modes.set(.kitty_paste_events, true);
|
||||
|
||||
try testing.expect(try handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} },
|
||||
}));
|
||||
try testing.expectEqualStrings("hello", S.written.items);
|
||||
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
|
||||
@@ -5813,7 +6007,7 @@ test "paste: text source never becomes an event" {
|
||||
|
||||
try testing.expect(try handler.paste(.{
|
||||
.source = .text,
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "committed" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "committed" }} },
|
||||
}));
|
||||
try testing.expectEqualStrings("committed", S.written.items);
|
||||
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
|
||||
@@ -5834,7 +6028,7 @@ test "paste: mode 5522 without entropy fails and records no grant" {
|
||||
t.modes.set(.kitty_paste_events, true);
|
||||
|
||||
try testing.expectError(error.EntropyUnavailable, handler.paste(.{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "secret" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} },
|
||||
}));
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
|
||||
@@ -5842,7 +6036,7 @@ test "paste: mode 5522 without entropy fails and records no grant" {
|
||||
// Text pastes need no entropy and still work.
|
||||
try testing.expect(try handler.paste(.{
|
||||
.source = .text,
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
|
||||
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} },
|
||||
}));
|
||||
try testing.expectEqualStrings("hello", S.written.items);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user