mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal: add shared paste core with Kitty clipboard paste events
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -95,6 +95,9 @@ 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 Cursor = Screen.Cursor;
|
||||
pub const CursorStyle = Screen.CursorStyle;
|
||||
pub const CursorStyleReq = terminal.CursorStyle;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -105,13 +105,30 @@ 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.
|
||||
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 io.randomSecure(&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 +199,20 @@ 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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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 PasteSource = paste.Source;
|
||||
pub const Cursor = Screen.Cursor;
|
||||
pub const CursorStyle = Screen.CursorStyle;
|
||||
pub const CursorStyleReq = ansi.CursorStyle;
|
||||
|
||||
185
src/terminal/paste.zig
Normal file
185
src/terminal/paste.zig
Normal file
@@ -0,0 +1,185 @@
|
||||
//! 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. The data is not written.
|
||||
//! * 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 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.
|
||||
///
|
||||
/// C: GhosttyPasteSource
|
||||
pub const Source = lib.Enum(lib.target, &.{
|
||||
// The user pasted from a clipboard: keybind, menu, middle click.
|
||||
"clipboard",
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
/// 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. Only a user-initiated clipboard paste
|
||||
/// may become a paste event; text insertion always writes text.
|
||||
source: Source = .clipboard,
|
||||
|
||||
/// 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
|
||||
/// flow is to call with false, confirm with the user on
|
||||
/// error.UnsafePaste, and call again with true.
|
||||
allow_unsafe: bool = false,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
writer: *std.Io.Writer,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
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 (req.source == .clipboard and
|
||||
ctx.can_event and
|
||||
ctx.terminal.modes.get(.kitty_paste_events))
|
||||
{
|
||||
try pasteKittyEvent(ctx, 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;
|
||||
} 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.
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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,
|
||||
.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 {
|
||||
// The behavior is tested end to end through the stream handler
|
||||
// (stream_terminal.zig), which is the primary caller.
|
||||
std.testing.refAllDecls(@This());
|
||||
}
|
||||
Reference in New Issue
Block a user