mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-15 02:12:01 +00:00
Merge branch 'ghostty-org:main' into main
This commit is contained in:
@@ -94,6 +94,7 @@ extern "C" {
|
||||
#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */
|
||||
#define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */
|
||||
#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */
|
||||
#define GHOSTTY_MODE_PASTE_EVENTS (ghostty_mode_new(5522, false)) /**< Kitty clipboard protocol paste events */
|
||||
/** @} */
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,9 +53,9 @@ fn decodeScalar(
|
||||
/// For non-SIMD enabled builds, we trim the padding from the end of the
|
||||
/// base64 input in order to get identical output with the SIMD version.
|
||||
fn scalarInput(input: []const u8) []const u8 {
|
||||
var i: usize = 0;
|
||||
while (input[input.len - i - 1] == '=') i += 1;
|
||||
return input[0 .. input.len - i];
|
||||
var end = input.len;
|
||||
while (end > 0 and input[end - 1] == '=') end -= 1;
|
||||
return input[0..end];
|
||||
}
|
||||
|
||||
// base64.cpp
|
||||
@@ -75,6 +75,14 @@ test "base64 maxLen" {
|
||||
try testing.expectEqual(11, len);
|
||||
}
|
||||
|
||||
test "base64 empty input" {
|
||||
const testing = std.testing;
|
||||
var output: [0]u8 = .{};
|
||||
|
||||
try testing.expectEqual(0, maxLen(""));
|
||||
try testing.expectEqualStrings("", try decode("", &output));
|
||||
}
|
||||
|
||||
test "base64 decode" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -1323,14 +1323,21 @@ pub fn print(self: *Terminal, c: u21) !void {
|
||||
const old_rac = old_pin.rowAndCell();
|
||||
|
||||
if (new_pin.node == old_pin.node) {
|
||||
new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell);
|
||||
prev.cell.content_tag = .codepoint;
|
||||
new_pin.node.page().moveGrapheme(old_rac.cell, new_rac.cell);
|
||||
old_rac.cell.content_tag = .codepoint;
|
||||
new_rac.cell.content_tag = .codepoint_grapheme;
|
||||
new_rac.row.grapheme = true;
|
||||
} else {
|
||||
const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?;
|
||||
for (cps) |cp| {
|
||||
try self.screens.active.appendGrapheme(new_rac.cell, cp);
|
||||
// appendGrapheme can grow the cursor
|
||||
// page, so read the destination from
|
||||
// the cursor each time rather than
|
||||
// holding a pointer across the call.
|
||||
try self.screens.active.appendGrapheme(
|
||||
self.screens.active.cursor.page_cell,
|
||||
cp,
|
||||
);
|
||||
}
|
||||
old_pin.node.page().clearGrapheme(old_rac.cell);
|
||||
}
|
||||
@@ -1340,7 +1347,7 @@ pub fn print(self: *Terminal, c: u21) !void {
|
||||
|
||||
// Point prev.cell to our new previous cell that
|
||||
// we'll be appending graphemes to
|
||||
prev.cell = new_rac.cell;
|
||||
prev.cell = self.screens.active.cursor.page_cell;
|
||||
} else {
|
||||
self.printCell(
|
||||
0,
|
||||
@@ -1359,8 +1366,30 @@ pub fn print(self: *Terminal, c: u21) !void {
|
||||
|
||||
// Write our spacer, since prev.cell is now wide
|
||||
self.screens.active.cursorRight(1);
|
||||
|
||||
// Writing the spacer can grow the page to make room for
|
||||
// the cursor hyperlink. Growing replaces the page, which
|
||||
// invalidates `prev.cell`. Record the page identity first
|
||||
// so the common case where nothing grows stays free.
|
||||
//
|
||||
// A pointer comparison alone isn't enough: pages are
|
||||
// pooled, so a replacement can reuse the same address.
|
||||
// The serial makes the pair a unique identity.
|
||||
const spacer_node = self.screens.active.cursor.page_pin.node;
|
||||
const spacer_serial = spacer_node.serial;
|
||||
|
||||
self.printCell(0, .spacer_tail);
|
||||
|
||||
if (self.screens.active.cursor.page_pin.node != spacer_node or
|
||||
self.screens.active.cursor.page_pin.node.serial != spacer_serial)
|
||||
{
|
||||
@branchHint(.unlikely);
|
||||
|
||||
// The cursor is on the spacer tail we just wrote, so
|
||||
// the wide cell we append to is the one to its left.
|
||||
prev.cell = self.screens.active.cursorCellLeft(1);
|
||||
}
|
||||
|
||||
// Move the cursor again so we're beyond our spacer
|
||||
if (self.screens.active.cursor.x == right_limit - 1) {
|
||||
self.screens.active.cursor.pending_wrap = true;
|
||||
@@ -1704,7 +1733,11 @@ fn printCell(
|
||||
self.screens.active.cursorSetHyperlink() catch |err| {
|
||||
@branchHint(.unlikely);
|
||||
log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err});
|
||||
assert(!cell.hyperlink);
|
||||
|
||||
// A partially successful grow can replace the page even when the
|
||||
// call fails, so `cell` may be stale here. The cursor pointers are
|
||||
// always reloaded, so read the cell through the cursor.
|
||||
assert(!self.screens.active.cursor.page_cell.hyperlink);
|
||||
};
|
||||
} else if (had_hyperlink) {
|
||||
// If the previous cell had a hyperlink then we need to clear it.
|
||||
@@ -6132,6 +6165,106 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: VS16 widening when the spacer tail grows the page" {
|
||||
// Regression test for a stale cell pointer in print's grapheme `.wide`
|
||||
// path: writing the spacer tail can grow the page to fit the hyperlink,
|
||||
// which replaces the page and invalidates the pointer to the wide cell.
|
||||
var t = try init(testing.io, testing.allocator, .{ .rows = 10, .cols = 20 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
t.modes.set(.grapheme_cluster, true);
|
||||
try t.screens.active.startHyperlink("http://example.com", null);
|
||||
|
||||
// Fill the page hyperlink map until a single slot is left. The '#' below
|
||||
// takes that slot so the spacer tail is what forces the page to grow.
|
||||
while (true) {
|
||||
const page = t.screens.active.cursor.page_pin.node.page();
|
||||
const map = page.hyperlink_map.map(page.memory);
|
||||
if (map.maxLoad() - map.count() == 1) break;
|
||||
try t.print('x');
|
||||
}
|
||||
|
||||
const x = t.screens.active.cursor.x;
|
||||
const y = t.screens.active.cursor.y;
|
||||
try t.print('#');
|
||||
|
||||
// Without the fix this crashed appending to a freed page.
|
||||
try t.print(0xFE0F);
|
||||
|
||||
{
|
||||
// '#' is wide and carries the VS16 grapheme.
|
||||
const list_cell = t.screens.active.pages.getCell(.{ .active = .{
|
||||
.x = x,
|
||||
.y = y,
|
||||
} }).?;
|
||||
const cell = list_cell.cell;
|
||||
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data);
|
||||
try testing.expectEqual(Cell.Wide.wide, cell.wide);
|
||||
try testing.expect(cell.hasGrapheme());
|
||||
try testing.expectEqualSlices(
|
||||
u21,
|
||||
&.{0xFE0F},
|
||||
list_cell.node.page().lookupGrapheme(cell).?,
|
||||
);
|
||||
}
|
||||
{
|
||||
const list_cell = t.screens.active.pages.getCell(.{ .active = .{
|
||||
.x = x + 1,
|
||||
.y = y,
|
||||
} }).?;
|
||||
try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: grapheme transfer when widening wraps to the next line" {
|
||||
// Covers print's grapheme `.wide` path where the previous cell already
|
||||
// holds grapheme data and has to be moved to the wrapped row.
|
||||
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
t.modes.set(.grapheme_cluster, true);
|
||||
t.cursorRight(2);
|
||||
|
||||
// A narrow emoji, then ZWJ, then a second emoji. The ZWJ attaches
|
||||
// without changing the width, so the cell has grapheme data by the time
|
||||
// the second emoji widens it.
|
||||
try t.print(0x263A);
|
||||
try t.print(0x200D);
|
||||
try t.print(0x2764);
|
||||
|
||||
{
|
||||
// The old cell becomes a spacer head on the wrapped row.
|
||||
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{
|
||||
.x = 2,
|
||||
.y = 0,
|
||||
} }).?;
|
||||
try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide);
|
||||
try testing.expect(list_cell.row.wrap);
|
||||
}
|
||||
{
|
||||
// The grapheme moved with the base codepoint.
|
||||
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 1,
|
||||
} }).?;
|
||||
const cell = list_cell.cell;
|
||||
try testing.expectEqual(@as(u21, 0x263A), cell.content.codepoint.data);
|
||||
try testing.expectEqual(Cell.Wide.wide, cell.wide);
|
||||
try testing.expectEqualSlices(
|
||||
u21,
|
||||
&.{ 0x200D, 0x2764 },
|
||||
list_cell.node.page().lookupGrapheme(cell).?,
|
||||
);
|
||||
}
|
||||
{
|
||||
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{
|
||||
.x = 1,
|
||||
.y = 1,
|
||||
} }).?;
|
||||
try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: VS16 to make wide character with pending wrap" {
|
||||
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// The clipboard destination for a write.
|
||||
pub const Location = enum(c_int) {
|
||||
standard = 0,
|
||||
@@ -6,6 +8,21 @@ pub const Location = enum(c_int) {
|
||||
_,
|
||||
};
|
||||
|
||||
/// MIME types that name plain text across the platforms terminals run
|
||||
/// on. We accept the union everywhere since serving text under any of these
|
||||
/// names is harmless.
|
||||
pub fn isTextMime(mime: []const u8) bool {
|
||||
const names: []const []const u8 = &.{
|
||||
"text/plain",
|
||||
"text/plain;charset=utf-8",
|
||||
"UTF8_STRING",
|
||||
"TEXT",
|
||||
"STRING",
|
||||
};
|
||||
for (names) |n| if (std.mem.eql(u8, mime, n)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// A single representation of clipboard data.
|
||||
///
|
||||
/// The MIME type and data are borrowed and only valid for the duration of a
|
||||
@@ -34,3 +51,11 @@ pub const WriteResult = enum(c_int) {
|
||||
io_error = 5,
|
||||
_,
|
||||
};
|
||||
|
||||
test isTextMime {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isTextMime("text/plain"));
|
||||
try testing.expect(isTextMime("UTF8_STRING"));
|
||||
try testing.expect(!isTextMime("image/png"));
|
||||
try testing.expect(!isTextMime("."));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const build_options = @import("terminal_options");
|
||||
|
||||
const key = @import("kitty/key.zig");
|
||||
pub const clipboard = @import("kitty/clipboard.zig");
|
||||
pub const color = @import("kitty/color.zig");
|
||||
pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {};
|
||||
|
||||
|
||||
70
src/terminal/kitty/clipboard.zig
Normal file
70
src/terminal/kitty/clipboard.zig
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Kitty clipboard protocol (OSC 5522).
|
||||
//!
|
||||
//! This implements the protocol semantics on top of the raw OSC capture:
|
||||
//! src/terminal/osc/parsers/kitty_clipboard_protocol.zig:
|
||||
//!
|
||||
//! The behavior here is modeled on the kitty reference implementation
|
||||
//! (kitty/clipboard.py) rather than only the prose spec, since the two
|
||||
//! disagree in places. Notable reference behaviors we reproduce:
|
||||
//!
|
||||
//! * Malformed metadata (any record without '=', including an empty
|
||||
//! metadata section), an unknown or missing `type`, and invalid
|
||||
//! base64 in `mime`, `name`, or `pw` all silently drop the request
|
||||
//! with no response.
|
||||
//! * `mime`, `name`, and `pw` metadata values are base64-encoded UTF-8;
|
||||
//! everything else is verbatim. Unknown keys are ignored.
|
||||
//! * `id` is sanitized by stripping characters outside [a-zA-Z0-9-_+.]
|
||||
//! and truncating to 512 bytes, then echoed verbatim in every
|
||||
//! response packet (omitted when empty).
|
||||
//! * A write data chunk with invalid base64 is dropped and the
|
||||
//! transaction continues; it is not a protocol error.
|
||||
//! * A `type=write` silently replaces any in-flight transaction. A
|
||||
//! commit (`type=wdata` without a MIME type) with no in-flight
|
||||
//! transaction is silently ignored.
|
||||
//! * Oversized writes are truncated and still complete with DONE.
|
||||
//! * Responses never send a payload section for an empty payload,
|
||||
//! except the targets ('.') listing DATA packet which is always sent.
|
||||
//!
|
||||
//! I plan to open an upstream issue asking for clarification on these
|
||||
//! once I implement this.
|
||||
//!
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
|
||||
|
||||
const oscpkg = @import("../osc.zig");
|
||||
const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig");
|
||||
const command = @import("clipboard_command.zig");
|
||||
const write = @import("clipboard_write.zig");
|
||||
const response = @import("clipboard_response.zig");
|
||||
const grants = @import("clipboard_grants.zig");
|
||||
|
||||
pub const OSC = protocol.OSC;
|
||||
pub const Operation = protocol.Operation;
|
||||
pub const Status = protocol.Status;
|
||||
pub const Terminator = oscpkg.Terminator;
|
||||
|
||||
pub const Metadata = command.Metadata;
|
||||
pub const Payload = command.Payload;
|
||||
pub const max_id_len = command.max_id_len;
|
||||
pub const max_pw_len = command.max_pw_len;
|
||||
pub const max_mime_len = command.max_mime_len;
|
||||
pub const max_name_len = command.max_name_len;
|
||||
|
||||
pub const Content = write.Content;
|
||||
pub const WriteState = write.WriteState;
|
||||
pub const max_write_size = write.max_write_size;
|
||||
pub const max_write_mimes = write.max_write_mimes;
|
||||
pub const max_write_aliases = write.max_write_aliases;
|
||||
|
||||
pub const Response = response.Response;
|
||||
pub const ReadSuccess = response.ReadSuccess;
|
||||
pub const read_chunk_size = response.read_chunk_size;
|
||||
pub const max_read_mimes = response.max_read_mimes;
|
||||
pub const targets_mime = response.targets_mime;
|
||||
|
||||
pub const Grants = grants.Grants;
|
||||
pub const otp_len = grants.otp_len;
|
||||
pub const generateOtp = grants.generateOtp;
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
}
|
||||
386
src/terminal/kitty/clipboard_command.zig
Normal file
386
src/terminal/kitty/clipboard_command.zig
Normal file
@@ -0,0 +1,386 @@
|
||||
//! Kitty clipboard protocol (OSC 5522) request decoding.
|
||||
//!
|
||||
//! OSC 5522 format: `<OSC>5522;metadata;payload<ST>`
|
||||
//!
|
||||
//! Per the spec: "metadata is a colon separated list of key-value
|
||||
//! pairs and payload is base64 encoded data."
|
||||
//!
|
||||
//! This contains the logic for parsing this.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const simd = @import("../../simd/main.zig");
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig");
|
||||
|
||||
const Operation = protocol.Operation;
|
||||
|
||||
/// Maximum id length, nothing specified but this is the limit used
|
||||
/// in Kitty's source so we'll match it.
|
||||
pub const max_id_len = 512;
|
||||
|
||||
/// Maximum decoded password length. Kitty has no limit. Passwords are
|
||||
/// UUID-sized in practice so anything longer simply never matches a
|
||||
/// stored grant anyway.
|
||||
pub const max_pw_len = 128;
|
||||
|
||||
/// Maximum decoded MIME type length. Kitty has no limit but real MIME
|
||||
/// types are tiny; anything longer drops the packet.
|
||||
pub const max_mime_len = 256;
|
||||
|
||||
/// Maximum decoded name length we bother validating. Longer names are
|
||||
/// treated as present without validation; only their presence matters.
|
||||
pub const max_name_len = 256;
|
||||
|
||||
/// The decoded, validated metadata of one OSC 5522 sequence.
|
||||
///
|
||||
/// All slice values are allocated from the allocator given to parse and
|
||||
/// are sized to their contents. Callers are expected to pass an arena
|
||||
/// scoped to handling the packet. There is no deinit.
|
||||
pub const Metadata = struct {
|
||||
op: Operation,
|
||||
|
||||
/// The clipboard this operation targets. Per the spec: "To read
|
||||
/// from the primary selection instead of the clipboard, add the
|
||||
/// key `loc=primary` to the metadata section." Any other value
|
||||
/// means the clipboard, so only standard and primary are possible
|
||||
/// here.
|
||||
loc: clipboard.Location = .standard,
|
||||
|
||||
/// Sanitized id: invalid characters stripped, truncated to
|
||||
/// max_id_len. Empty means no id.
|
||||
id: []const u8 = "",
|
||||
|
||||
/// Decoded mime metadata value. Empty means absent; kitty treats an
|
||||
/// empty mime the same as a missing one everywhere it matters (a
|
||||
/// wdata packet with either commits the transaction).
|
||||
mime: []const u8 = "",
|
||||
|
||||
/// Decoded password. Empty means absent. Per the spec:
|
||||
/// "Specifying a password without a human friendly name is
|
||||
/// equivalent to not specifying a password and the terminal must
|
||||
/// treat the request as though it had no password."
|
||||
pw: []const u8 = "",
|
||||
|
||||
/// True if a non-empty (valid) name was given. We don't retain the
|
||||
/// name contents; it exists to opt into password grants.
|
||||
has_name: bool = false,
|
||||
|
||||
/// Parse the metadata field. The raw value is expected to be exactly
|
||||
/// the metadata (prefix and payload and separators stripped out).
|
||||
///
|
||||
/// A null result means it was invalid but without any response.
|
||||
/// Silently drop the OSC.
|
||||
pub fn parse(
|
||||
alloc: Allocator,
|
||||
raw: []const u8,
|
||||
) Allocator.Error!?Metadata {
|
||||
var op_raw: ?[]const u8 = null;
|
||||
var result: Metadata = .{ .op = undefined };
|
||||
|
||||
// Note this loop visits every record even though an empty raw
|
||||
// string yields a single empty record: that record has no '='
|
||||
// and correctly drops the packet, matching kitty which requires
|
||||
// at least a valid `type` record.
|
||||
var it = std.mem.splitScalar(u8, raw, ':');
|
||||
while (it.next()) |record| {
|
||||
// Every record must be key=value. Any single invalid record
|
||||
// is dropped, matching Kitty's behavior.
|
||||
const eql_idx = std.mem.indexOfScalar(u8, record, '=') orelse return null;
|
||||
const key = record[0..eql_idx];
|
||||
const value = record[eql_idx + 1 ..];
|
||||
|
||||
if (std.mem.eql(u8, key, "type")) {
|
||||
// Validated after the loop: a duplicate key's last
|
||||
// occurrence wins. This isn't specified but its how Kitty
|
||||
// works.
|
||||
op_raw = value;
|
||||
} else if (std.mem.eql(u8, key, "loc")) {
|
||||
result.loc = if (std.mem.eql(u8, value, "primary"))
|
||||
.primary
|
||||
else
|
||||
.standard;
|
||||
} else if (std.mem.eql(u8, key, "id")) {
|
||||
result.id = try sanitizeId(alloc, value);
|
||||
} else if (std.mem.eql(u8, key, "mime")) {
|
||||
result.mime = decodeValue(
|
||||
alloc,
|
||||
value,
|
||||
max_mime_len,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Overflow, error.Invalid => return null,
|
||||
};
|
||||
} else if (std.mem.eql(u8, key, "pw")) {
|
||||
result.pw = decodeValue(
|
||||
alloc,
|
||||
value,
|
||||
max_pw_len,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// An over-long password behaves as if none was
|
||||
// given: it can never match a stored grant.
|
||||
error.Overflow => "",
|
||||
error.Invalid => return null,
|
||||
};
|
||||
} else if (std.mem.eql(u8, key, "name")) {
|
||||
// We only need to know whether a (non-empty) name was
|
||||
// given; the contents are decoded for validation only.
|
||||
result.has_name = has_name: {
|
||||
const name = decodeValue(
|
||||
alloc,
|
||||
value,
|
||||
max_name_len,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// Over-long names are accepted as present but
|
||||
// not validated further.
|
||||
error.Overflow => break :has_name true,
|
||||
error.Invalid => return null,
|
||||
};
|
||||
break :has_name name.len > 0;
|
||||
};
|
||||
}
|
||||
// Unknown keys are ignored.
|
||||
}
|
||||
|
||||
// A missing or unknown operation drops the request.
|
||||
result.op = Operation.init(op_raw orelse return null) orelse return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Sanitize the ID according to the spec:
|
||||
///
|
||||
/// Valid ids must include only characters from the set: [a-zA-Z0-9-_+.].
|
||||
/// Any other characters must be stripped out from the id by the terminal
|
||||
/// emulator before retransmitting it.
|
||||
fn sanitizeId(alloc: Allocator, value: []const u8) Allocator.Error![]const u8 {
|
||||
var list: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer list.deinit(alloc);
|
||||
for (value) |c| {
|
||||
switch (c) {
|
||||
'a'...'z', 'A'...'Z', '0'...'9', '-', '_', '+', '.' => {},
|
||||
else => continue,
|
||||
}
|
||||
if (list.items.len >= max_id_len) break;
|
||||
try list.append(alloc, c);
|
||||
}
|
||||
return try list.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// Base64-decode a metadata value. Per the spec these values "are
|
||||
/// UTF-8 strings that are base64 encoded", so the decoded result
|
||||
/// must be valid UTF-8.
|
||||
fn decodeValue(alloc: Allocator, value: []const u8, max_len: usize) error{
|
||||
OutOfMemory,
|
||||
Overflow,
|
||||
Invalid,
|
||||
}![]const u8 {
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
|
||||
// Avoid hostile large payloads.
|
||||
if (value.len > Encoder.calcSize(max_len)) return error.Overflow;
|
||||
|
||||
// Decode
|
||||
const buf = try alloc.alloc(u8, simd.base64.maxLen(value));
|
||||
errdefer alloc.free(buf);
|
||||
const decoded = simd.base64.decode(
|
||||
value,
|
||||
buf,
|
||||
) catch return error.Invalid;
|
||||
|
||||
// Must be valid UTF-8
|
||||
if (!std.unicode.utf8ValidateSlice(decoded)) return error.Invalid;
|
||||
if (decoded.len > max_len) return error.Overflow;
|
||||
return decoded;
|
||||
}
|
||||
};
|
||||
|
||||
/// A decoded base64 payload of one OSC 5522 sequence, e.g. the MIME
|
||||
/// type list of a read request or the alias list of a walias packet.
|
||||
/// The data slice aliases the allocated buf.
|
||||
pub const Payload = struct {
|
||||
buf: []u8,
|
||||
data: []const u8,
|
||||
|
||||
/// Decode a base64 payload into freshly allocated memory. An
|
||||
/// invalid payload means the sequence is dropped.
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
payload: []const u8,
|
||||
) error{ OutOfMemory, Invalid }!Payload {
|
||||
const buf = try alloc.alloc(u8, simd.base64.maxLen(payload));
|
||||
errdefer alloc.free(buf);
|
||||
const data = simd.base64.decode(
|
||||
payload,
|
||||
buf,
|
||||
) catch return error.Invalid;
|
||||
return .{ .buf = buf, .data = data };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Payload, alloc: Allocator) void {
|
||||
alloc.free(self.buf);
|
||||
}
|
||||
|
||||
/// Iterate the whitespace-separated MIME types of the payload.
|
||||
/// Matches Python str.split() used by kitty.
|
||||
pub fn mimeIterator(self: *const Payload) std.mem.TokenIterator(u8, .any) {
|
||||
return std.mem.tokenizeAny(
|
||||
u8,
|
||||
self.data,
|
||||
&std.ascii.whitespace,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
test "metadata: empty is dropped" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "")) == null);
|
||||
}
|
||||
|
||||
test "metadata: record without = is dropped" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=read:bare")) == null);
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "bare:type=read")) == null);
|
||||
}
|
||||
|
||||
test "metadata: missing or unknown type is dropped" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "loc=primary")) == null);
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=bobr")) == null);
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=")) == null);
|
||||
}
|
||||
|
||||
test "metadata: duplicate keys keep the last occurrence" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expectEqual(
|
||||
Operation.read,
|
||||
(try Metadata.parse(arena.allocator(), "type=bobr:type=read")).?.op,
|
||||
);
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=read:type=bobr")) == null);
|
||||
}
|
||||
|
||||
test "metadata: basic read" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read")).?;
|
||||
try testing.expectEqual(Operation.read, meta.op);
|
||||
try testing.expect(meta.loc == .standard);
|
||||
try testing.expectEqual(@as(usize, 0), meta.id.len);
|
||||
}
|
||||
|
||||
test "metadata: unknown keys ignored" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:bobr=kurwa")).?;
|
||||
try testing.expectEqual(Operation.read, meta.op);
|
||||
}
|
||||
|
||||
test "metadata: loc" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=primary")).?.loc == .primary);
|
||||
// Anything other than "primary" means the clipboard; it is not an
|
||||
// error.
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=bobr")).?.loc == .standard);
|
||||
}
|
||||
|
||||
test "metadata: id sanitized" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
{
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:id=abc-123_x.Y+z")).?;
|
||||
try testing.expectEqualStrings("abc-123_x.Y+z", meta.id);
|
||||
}
|
||||
{
|
||||
// Invalid characters are stripped, not rejected.
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:id=*4 2*")).?;
|
||||
try testing.expectEqualStrings("42", meta.id);
|
||||
}
|
||||
}
|
||||
|
||||
test "metadata: id truncated to max" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const raw = "type=read:id=" ++ "a" ** (max_id_len + 100);
|
||||
const meta = (try Metadata.parse(arena.allocator(), raw)).?;
|
||||
try testing.expectEqual(@as(usize, max_id_len), meta.id.len);
|
||||
}
|
||||
|
||||
test "metadata: mime decoded" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
// "text/plain"
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=wdata:mime=dGV4dC9wbGFpbg==")).?;
|
||||
try testing.expectEqualStrings("text/plain", meta.mime);
|
||||
}
|
||||
|
||||
test "metadata: invalid mime base64 dropped" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=wdata:mime=!!!")) == null);
|
||||
}
|
||||
|
||||
test "metadata: invalid mime utf8 dropped" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
// base64 of 0xff 0xfe
|
||||
try testing.expect((try Metadata.parse(arena.allocator(), "type=wdata:mime=//4=")) == null);
|
||||
}
|
||||
|
||||
test "metadata: pw and name" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
// pw="secret", name="app"
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?;
|
||||
try testing.expectEqualStrings("secret", meta.pw);
|
||||
try testing.expect(meta.has_name);
|
||||
}
|
||||
|
||||
test "metadata: empty name" {
|
||||
const testing = std.testing;
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?;
|
||||
try testing.expect(!meta.has_name);
|
||||
}
|
||||
|
||||
test "payload: mime iterator" {
|
||||
const testing = std.testing;
|
||||
// base64 of "text/plain text/html\ntext/uri-list"
|
||||
const payload = try Payload.init(
|
||||
testing.allocator,
|
||||
"dGV4dC9wbGFpbiAgdGV4dC9odG1sCnRleHQvdXJpLWxpc3Q=",
|
||||
);
|
||||
defer payload.deinit(testing.allocator);
|
||||
var it = payload.mimeIterator();
|
||||
try testing.expectEqualStrings("text/plain", it.next().?);
|
||||
try testing.expectEqualStrings("text/html", it.next().?);
|
||||
try testing.expectEqualStrings("text/uri-list", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
test "payload: invalid base64" {
|
||||
const testing = std.testing;
|
||||
try testing.expectError(
|
||||
error.Invalid,
|
||||
Payload.init(testing.allocator, "!!!"),
|
||||
);
|
||||
}
|
||||
184
src/terminal/kitty/clipboard_grants.zig
Normal file
184
src/terminal/kitty/clipboard_grants.zig
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Kitty clipboard protocol (OSC 5522) session password grants:
|
||||
//! requests carrying a granted password skip the permission prompt, and
|
||||
//! paste events mint one-time passwords.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const clipboard_command = @import("clipboard_command.zig");
|
||||
|
||||
const max_pw_len = clipboard_command.max_pw_len;
|
||||
|
||||
/// Session password grants, used to skip permission prompts for
|
||||
/// requests carrying a known pw. Callers can choose to scope these
|
||||
/// however they want, e.g. Kitty does it per window and the spec
|
||||
/// doesn't demand anything.
|
||||
pub const Grants = struct {
|
||||
entries: std.ArrayListUnmanaged(Entry) = .empty,
|
||||
|
||||
const max_entries = 32;
|
||||
|
||||
pub const Direction = enum { read, write };
|
||||
|
||||
const Entry = struct {
|
||||
/// Owned by the allocator passed to grant.
|
||||
pw: []const u8,
|
||||
read: bool = false,
|
||||
write: bool = false,
|
||||
one_time: bool = false,
|
||||
};
|
||||
|
||||
pub fn deinit(self: *Grants, alloc: Allocator) void {
|
||||
for (self.entries.items) |entry| alloc.free(entry.pw);
|
||||
self.entries.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Record a grant for pw. An existing grant for the same password
|
||||
/// gains the new direction.
|
||||
pub fn grant(
|
||||
self: *Grants,
|
||||
alloc: Allocator,
|
||||
pw: []const u8,
|
||||
dir: Direction,
|
||||
one_time: bool,
|
||||
) Allocator.Error!void {
|
||||
if (pw.len == 0 or pw.len > max_pw_len) return;
|
||||
|
||||
const entry: *Entry = entry: {
|
||||
if (self.findIndex(pw)) |idx| {
|
||||
const entry = &self.entries.items[idx];
|
||||
entry.one_time = entry.one_time and one_time;
|
||||
break :entry entry;
|
||||
}
|
||||
|
||||
// Evict the oldest grant once full.
|
||||
if (self.entries.items.len >= max_entries) {
|
||||
const oldest = self.entries.orderedRemove(0);
|
||||
alloc.free(oldest.pw);
|
||||
}
|
||||
|
||||
const owned = try alloc.dupe(u8, pw);
|
||||
errdefer alloc.free(owned);
|
||||
try self.entries.append(alloc, .{
|
||||
.pw = owned,
|
||||
.one_time = one_time,
|
||||
});
|
||||
break :entry &self.entries.items[self.entries.items.len - 1];
|
||||
};
|
||||
|
||||
switch (dir) {
|
||||
.read => entry.read = true,
|
||||
.write => entry.write = true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether pw grants the given direction. A one-time grant is
|
||||
/// consumed by this check even when the direction doesn't match,
|
||||
/// matching kitty's pop-on-check behavior.
|
||||
pub fn use(
|
||||
self: *Grants,
|
||||
alloc: Allocator,
|
||||
pw: []const u8,
|
||||
dir: Direction,
|
||||
) bool {
|
||||
if (pw.len == 0) return false;
|
||||
const idx = self.findIndex(pw) orelse return false;
|
||||
const entry = &self.entries.items[idx];
|
||||
const allowed = switch (dir) {
|
||||
.read => entry.read,
|
||||
.write => entry.write,
|
||||
};
|
||||
if (entry.one_time) {
|
||||
const removed = self.entries.swapRemove(idx);
|
||||
alloc.free(removed.pw);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
fn findIndex(self: *const Grants, pw: []const u8) ?usize {
|
||||
for (self.entries.items, 0..) |*entry, idx| {
|
||||
if (std.mem.eql(u8, entry.pw, pw)) return idx;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// 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";
|
||||
var result: [otp_len]u8 = undefined;
|
||||
for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)];
|
||||
return result;
|
||||
}
|
||||
|
||||
test "grants: basic grant and use" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try testing.expect(!grants.use(alloc, "pw1", .read));
|
||||
|
||||
try grants.grant(alloc, "pw1", .read, false);
|
||||
try testing.expect(grants.use(alloc, "pw1", .read));
|
||||
// Persistent grants survive use.
|
||||
try testing.expect(grants.use(alloc, "pw1", .read));
|
||||
try testing.expect(!grants.use(alloc, "pw1", .write));
|
||||
}
|
||||
|
||||
test "grants: one-time consumed on check" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try grants.grant(alloc, "otp", .read, true);
|
||||
try testing.expect(grants.use(alloc, "otp", .read));
|
||||
try testing.expect(!grants.use(alloc, "otp", .read));
|
||||
}
|
||||
|
||||
test "grants: one-time consumed even on direction mismatch" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try grants.grant(alloc, "otp", .read, true);
|
||||
try testing.expect(!grants.use(alloc, "otp", .write));
|
||||
try testing.expect(!grants.use(alloc, "otp", .read));
|
||||
}
|
||||
|
||||
test "grants: directions are independent" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try grants.grant(alloc, "pw", .read, false);
|
||||
try grants.grant(alloc, "pw", .write, false);
|
||||
try testing.expect(grants.use(alloc, "pw", .read));
|
||||
try testing.expect(grants.use(alloc, "pw", .write));
|
||||
}
|
||||
|
||||
test "grants: capacity evicts the oldest" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
|
||||
var buf: [8]u8 = undefined;
|
||||
for (0..Grants.max_entries + 1) |i| {
|
||||
const pw = try std.fmt.bufPrint(&buf, "pw{}", .{i});
|
||||
try grants.grant(alloc, pw, .read, false);
|
||||
}
|
||||
|
||||
// The oldest grant was evicted; the newest survives.
|
||||
try testing.expect(!grants.use(alloc, "pw0", .read));
|
||||
const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries});
|
||||
try testing.expect(grants.use(alloc, newest, .read));
|
||||
}
|
||||
385
src/terminal/kitty/clipboard_response.zig
Normal file
385
src/terminal/kitty/clipboard_response.zig
Normal file
@@ -0,0 +1,385 @@
|
||||
//! Kitty clipboard protocol (OSC 5522) response encoding.
|
||||
|
||||
const std = @import("std");
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const oscpkg = @import("../osc.zig");
|
||||
const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig");
|
||||
|
||||
const b64 = std.base64.standard.Encoder;
|
||||
const Content = clipboard.Content;
|
||||
const Operation = protocol.Operation;
|
||||
const Status = protocol.Status;
|
||||
const Terminator = oscpkg.Terminator;
|
||||
|
||||
/// Maximum raw (pre-base64) bytes per DATA packet in read responses.
|
||||
/// This is specified by the protocol.
|
||||
pub const read_chunk_size = 4096;
|
||||
|
||||
/// Maximum requested MIME types served by a single read request.
|
||||
/// Requests beyond this simply see no DATA packets for the extras,
|
||||
/// which is how the protocol communicates an unavailable type anyway.
|
||||
pub const max_read_mimes = 4;
|
||||
|
||||
/// The special MIME type that requests the list of available types.
|
||||
pub const targets_mime = ".";
|
||||
|
||||
/// A single response packet.
|
||||
pub const Response = struct {
|
||||
op: Operation,
|
||||
status: Status,
|
||||
primary: bool = false,
|
||||
id: []const u8 = "",
|
||||
mime: ?[]const u8 = null,
|
||||
pw: ?[]const u8 = null,
|
||||
/// The raw payload; the encoder base64-encodes it. An empty payload
|
||||
/// emits no payload section at all (no ';').
|
||||
payload: []const u8 = "",
|
||||
terminator: Terminator = .st,
|
||||
|
||||
/// Encode the response. Errors may result in partially written data
|
||||
/// so it is up to callers to buffer it if they need to.
|
||||
pub fn encode(
|
||||
self: *const Response,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
try self.encodeMetadata(writer);
|
||||
if (self.payload.len > 0) {
|
||||
try writer.writeAll(";");
|
||||
try b64.encodeWriter(writer, self.payload);
|
||||
}
|
||||
try writer.writeAll(self.terminator.string());
|
||||
}
|
||||
|
||||
/// Encode the escape prefix and metadata section only: everything
|
||||
/// up to (and not including) the payload section and terminator.
|
||||
fn encodeMetadata(
|
||||
self: *const Response,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
// The exact order of fields here matches what Kitty does.
|
||||
try writer.print("\x1b]5522;type={t}:status={t}", .{ self.op, self.status });
|
||||
if (self.primary) try writer.writeAll(":loc=primary");
|
||||
if (self.id.len > 0) try writer.print(":id={s}", .{self.id});
|
||||
if (self.mime) |mime| {
|
||||
try writer.writeAll(":mime=");
|
||||
try b64.encodeWriter(writer, mime);
|
||||
}
|
||||
if (self.pw) |pw| {
|
||||
try writer.writeAll(":pw=");
|
||||
try b64.encodeWriter(writer, pw);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Encode a full successful read response: the OK packet, the targets
|
||||
/// listing if requested, DATA chunks for each served representation,
|
||||
/// and the final DONE packet. This is also the shape of an unsolicited
|
||||
/// paste event (list=true, pw set to the one-time password).
|
||||
pub const ReadSuccess = struct {
|
||||
primary: bool = false,
|
||||
id: []const u8 = "",
|
||||
|
||||
/// One-time password echoed in every packet. Only used for
|
||||
/// terminal-initiated paste events.
|
||||
pw: ?[]const u8 = null,
|
||||
|
||||
/// True when the targets ('.') listing was requested.
|
||||
list: bool = false,
|
||||
|
||||
/// The MIME types available on the clipboard, reported by the
|
||||
/// targets listing. Kitty reports these space-separated in one
|
||||
/// packet with a trailing newline when non-empty.
|
||||
available: []const []const u8 = &.{},
|
||||
|
||||
/// The representations to serve, in request order. Each entry's
|
||||
/// data is chunked into DATA packets under its own MIME type. An
|
||||
/// entry with empty data produces no packets, which is how the
|
||||
/// protocol communicates an unavailable type.
|
||||
contents: []const Content = &.{},
|
||||
|
||||
terminator: Terminator = .st,
|
||||
|
||||
pub fn encode(
|
||||
self: *const ReadSuccess,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
// Initial read response
|
||||
try (Response{
|
||||
.op = .read,
|
||||
.status = .OK,
|
||||
.primary = self.primary,
|
||||
.id = self.id,
|
||||
.pw = self.pw,
|
||||
.terminator = self.terminator,
|
||||
}).encode(writer);
|
||||
|
||||
// Listing of mimes if requested
|
||||
if (self.list) try self.encodeListing(writer);
|
||||
|
||||
// Encoding of each mime-type + content
|
||||
for (self.contents) |content| {
|
||||
var i: usize = 0;
|
||||
while (i < content.data.len) {
|
||||
const n = @min(content.data.len - i, read_chunk_size);
|
||||
try (Response{
|
||||
.op = .read,
|
||||
.status = .DATA,
|
||||
.id = self.id,
|
||||
.mime = content.mime,
|
||||
.pw = self.pw,
|
||||
.payload = content.data[i..][0..n],
|
||||
.terminator = self.terminator,
|
||||
}).encode(writer);
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing done.
|
||||
try (Response{
|
||||
.op = .read,
|
||||
.status = .DONE,
|
||||
.id = self.id,
|
||||
.pw = self.pw,
|
||||
.terminator = self.terminator,
|
||||
}).encode(writer);
|
||||
}
|
||||
|
||||
/// Encode the targets ('.') listing packet. The listing gets a
|
||||
/// trailing newline when non-empty.
|
||||
fn encodeListing(
|
||||
self: *const ReadSuccess,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
const listing: Response = .{
|
||||
.op = .read,
|
||||
.status = .DATA,
|
||||
.id = self.id,
|
||||
.mime = targets_mime,
|
||||
.pw = self.pw,
|
||||
.terminator = self.terminator,
|
||||
};
|
||||
try listing.encodeMetadata(writer);
|
||||
if (self.available.len > 0) {
|
||||
try writer.writeAll(";");
|
||||
|
||||
// Join the types into one chunk before encoding. The
|
||||
// listing is a DATA packet, so it shares the pre-encoding
|
||||
// chunk size bound of any other data packet; types that
|
||||
// don't fit are dropped (a listing that large doesn't
|
||||
// happen in practice).
|
||||
var raw: [read_chunk_size]u8 = undefined;
|
||||
var fixed: std.Io.Writer = .fixed(&raw);
|
||||
for (self.available, 0..) |mime, i| {
|
||||
const sep: usize = if (i > 0) 1 else 0;
|
||||
if (fixed.end + sep + mime.len + "\n".len > raw.len) break;
|
||||
if (i > 0) fixed.writeAll(" ") catch unreachable;
|
||||
fixed.writeAll(mime) catch unreachable;
|
||||
}
|
||||
fixed.writeAll("\n") catch unreachable;
|
||||
try b64.encodeWriter(writer, fixed.buffered());
|
||||
}
|
||||
try writer.writeAll(self.terminator.string());
|
||||
}
|
||||
};
|
||||
|
||||
test "response: basic status packet" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (Response{ .op = .write, .status = .DONE }).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=write:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "response: id echo and terminator" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (Response{
|
||||
.op = .write,
|
||||
.status = .EPERM,
|
||||
.id = "42",
|
||||
.terminator = .bel,
|
||||
}).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=write:status=EPERM:id=42\x07",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "response: key order type,status,loc,id,mime,pw and payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (Response{
|
||||
.op = .read,
|
||||
.status = .DATA,
|
||||
.primary = true,
|
||||
.id = "x",
|
||||
.mime = "text/plain",
|
||||
.pw = "otp",
|
||||
.payload = "Ghostty",
|
||||
}).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=DATA:loc=primary:id=x" ++
|
||||
":mime=dGV4dC9wbGFpbg==:pw=b3Rw;R2hvc3R0eQ==\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: empty request is OK then DONE" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{ .id = "7" }).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK:id=7\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE:id=7\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: targets listing with text" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{ .list = true, .available = &.{"text/plain"} }).encode(&writer);
|
||||
// "." => "Lg==", "text/plain\n" => "dGV4dC9wbGFpbgo="
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbgo=\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: targets listing joins multiple types" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{
|
||||
.list = true,
|
||||
.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\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: empty targets listing packet still sent" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{ .list = true }).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=Lg==\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: data chunks under requested mime" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "Ghostty" }},
|
||||
}).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: each representation carries its own data" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{
|
||||
.contents = &.{
|
||||
.{ .mime = "text/plain", .data = "hello" },
|
||||
.{ .mime = "image/png", .data = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" },
|
||||
},
|
||||
}).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=aW1hZ2UvcG5n;iVBORw0KGgo=\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: chunking at read_chunk_size" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const data = "z" ** (read_chunk_size + 1);
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
try (ReadSuccess{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = data }},
|
||||
}).encode(&aw.writer);
|
||||
|
||||
// OK + 2 DATA packets + DONE = 4 packets.
|
||||
const count = std.mem.count(u8, aw.written(), "\x1b]5522;");
|
||||
try testing.expectEqual(@as(usize, 4), count);
|
||||
|
||||
// The first chunk is exactly read_chunk_size bytes, base64 encoded
|
||||
// with padding.
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
var chunk_buf: [Encoder.calcSize(read_chunk_size)]u8 = undefined;
|
||||
const first = Encoder.encode(&chunk_buf, data[0..read_chunk_size]);
|
||||
try testing.expect(std.mem.indexOf(u8, aw.written(), first) != null);
|
||||
}
|
||||
|
||||
test "read success: no data packets for empty clipboard" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "" }},
|
||||
}).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
|
||||
test "read success: paste event carries pw in every packet" {
|
||||
const testing = std.testing;
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try (ReadSuccess{
|
||||
.list = true,
|
||||
.pw = "otp",
|
||||
.available = &.{"text/plain"},
|
||||
}).encode(&writer);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x1b\\" ++
|
||||
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\",
|
||||
writer.buffered(),
|
||||
);
|
||||
}
|
||||
443
src/terminal/kitty/clipboard_write.zig
Normal file
443
src/terminal/kitty/clipboard_write.zig
Normal file
@@ -0,0 +1,443 @@
|
||||
//! Kitty clipboard protocol (OSC 5522) write transactions: the
|
||||
//! stateful accumulation of wdata chunks and walias aliases until the
|
||||
//! commit packet arrives. See clipboard.zig for the protocol overview.
|
||||
|
||||
const std = @import("std");
|
||||
const assert = @import("../../quirks.zig").inlineAssert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const clipboard_command = @import("clipboard_command.zig");
|
||||
|
||||
const Metadata = clipboard_command.Metadata;
|
||||
const Payload = clipboard_command.Payload;
|
||||
const max_mime_len = clipboard_command.max_mime_len;
|
||||
|
||||
const log = std.log.scoped(.kitty_clipboard);
|
||||
|
||||
/// Maximum total decoded bytes accumulated by one write transaction.
|
||||
/// We hardcode this for now but probably will make this configurable
|
||||
/// later.
|
||||
pub const max_write_size = 32 * 1024 * 1024;
|
||||
|
||||
/// Maximum MIME types and aliases per write transaction.
|
||||
pub const max_write_mimes = 64;
|
||||
pub const max_write_aliases = 64;
|
||||
|
||||
/// One MIME representation of committed clipboard data. This is the
|
||||
/// same type the clipboard write effect consumes so committed contents
|
||||
/// can be passed through directly.
|
||||
pub const Content = clipboard.Content;
|
||||
|
||||
/// The state of one in-flight write transaction: a single `type=write`
|
||||
/// plus all the `wdata` chunks and `walias` aliases until completion
|
||||
/// or error.
|
||||
pub const WriteState = struct {
|
||||
arena: std.heap.ArenaAllocator,
|
||||
loc: clipboard.Location,
|
||||
id: []const u8,
|
||||
pw: []const u8,
|
||||
has_name: bool,
|
||||
spool: std.ArrayListUnmanaged(u8) = .empty,
|
||||
entries: std.ArrayListUnmanaged(Entry) = .empty,
|
||||
aliases: std.ArrayListUnmanaged(Alias) = .empty,
|
||||
|
||||
/// Index into entries currently receiving data.
|
||||
current: ?usize = null,
|
||||
|
||||
/// Set when max_write_size was exceeded; excess data is dropped
|
||||
/// but the write still completes.
|
||||
truncated: bool = false,
|
||||
|
||||
const Entry = struct {
|
||||
/// Owned by the transaction arena.
|
||||
mime: []const u8,
|
||||
start: usize = 0,
|
||||
len: usize = 0,
|
||||
};
|
||||
|
||||
const Alias = struct {
|
||||
/// Owned by the transaction arena.
|
||||
alias: []const u8,
|
||||
target: []const u8,
|
||||
};
|
||||
|
||||
/// Begin a transaction from a type=write packet.
|
||||
pub fn init(alloc: Allocator, meta: *const Metadata) Allocator.Error!WriteState {
|
||||
assert(meta.op == .write);
|
||||
var arena: std.heap.ArenaAllocator = .init(alloc);
|
||||
errdefer arena.deinit();
|
||||
const id = try arena.allocator().dupe(u8, meta.id);
|
||||
const pw = try arena.allocator().dupe(u8, meta.pw);
|
||||
return .{
|
||||
.arena = arena,
|
||||
.loc = meta.loc,
|
||||
.id = id,
|
||||
.pw = pw,
|
||||
.has_name = meta.has_name,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *WriteState, alloc: Allocator) void {
|
||||
self.spool.deinit(alloc);
|
||||
self.entries.deinit(alloc);
|
||||
self.aliases.deinit(alloc);
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
/// Accumulate one wdata chunk carrying data for meta.mime (which
|
||||
/// must be non-empty; an empty mime is a commit, not data).
|
||||
pub fn data(
|
||||
self: *WriteState,
|
||||
alloc: Allocator,
|
||||
meta: *const Metadata,
|
||||
payload: []const u8,
|
||||
) error{OutOfMemory}!void {
|
||||
assert(meta.op == .wdata);
|
||||
assert(meta.mime.len > 0);
|
||||
// Switch the receiving entry if this chunk is for a different
|
||||
// MIME type than the last one.
|
||||
entry: {
|
||||
if (self.current) |idx| {
|
||||
const entry = &self.entries.items[idx];
|
||||
if (std.mem.eql(u8, entry.mime, meta.mime)) {
|
||||
break :entry;
|
||||
}
|
||||
|
||||
// Finalize the previous region.
|
||||
entry.len = self.spool.items.len - entry.start;
|
||||
}
|
||||
|
||||
// Re-using an earlier MIME type starts a fresh region,
|
||||
// overwriting the previous mapping.
|
||||
for (self.entries.items, 0..) |*entry, idx| {
|
||||
if (std.mem.eql(u8, entry.mime, meta.mime)) {
|
||||
entry.start = self.spool.items.len;
|
||||
entry.len = 0;
|
||||
self.current = idx;
|
||||
break :entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (self.entries.items.len >= max_write_mimes) {
|
||||
log.warn(
|
||||
"clipboard write has too many MIME types, ignoring mime={s}",
|
||||
.{meta.mime},
|
||||
);
|
||||
self.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try self.entries.append(alloc, .{
|
||||
.mime = try self.arena.allocator().dupe(u8, meta.mime),
|
||||
.start = self.spool.items.len,
|
||||
});
|
||||
self.current = self.entries.items.len - 1;
|
||||
}
|
||||
|
||||
// Each packet's payload is independently base64-encoded: per
|
||||
// the spec, "payload is base64 encoded data" and clients chunk
|
||||
// the data before encoding. An invalid chunk is dropped and
|
||||
// the transaction continues.
|
||||
const decoded = Payload.init(
|
||||
alloc,
|
||||
payload,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Invalid => {
|
||||
log.warn("clipboard write chunk has invalid base64, ignoring chunk", .{});
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer decoded.deinit(alloc);
|
||||
|
||||
const remaining = max_write_size -| self.spool.items.len;
|
||||
const n = @min(decoded.data.len, remaining);
|
||||
try self.spool.appendSlice(alloc, decoded.data[0..n]);
|
||||
if (n < decoded.data.len) {
|
||||
self.truncated = true;
|
||||
logTruncatedOnce();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register aliases from a walias packet: meta.mime is the target
|
||||
/// (the type that carries data) and the payload is a base64-encoded,
|
||||
/// whitespace-separated list of aliases. Returns error.Invalid for
|
||||
/// an undecodable payload, which aborts the transaction with EINVAL.
|
||||
pub fn alias(
|
||||
self: *WriteState,
|
||||
alloc: Allocator,
|
||||
meta: *const Metadata,
|
||||
payload: []const u8,
|
||||
) error{ OutOfMemory, Invalid }!void {
|
||||
assert(meta.op == .walias);
|
||||
assert(meta.mime.len > 0);
|
||||
const decoded = try Payload.init(alloc, payload);
|
||||
defer decoded.deinit(alloc);
|
||||
var it = decoded.mimeIterator();
|
||||
|
||||
// Copy the target only if at least one valid alias exists.
|
||||
const target: []const u8 = target: {
|
||||
while (it.next()) |name| {
|
||||
if (name.len > max_mime_len) continue;
|
||||
break :target try self.arena.allocator().dupe(u8, meta.mime);
|
||||
}
|
||||
|
||||
// If we didn't find a target then ignore it.
|
||||
return;
|
||||
};
|
||||
|
||||
// Rewind so the alias that satisfied the check above is
|
||||
// associated too.
|
||||
it.reset();
|
||||
|
||||
// Associate the aliases
|
||||
while (it.next()) |name| {
|
||||
if (name.len > max_mime_len) continue;
|
||||
|
||||
// A repeated alias overwrites its previous target.
|
||||
for (self.aliases.items) |*a| {
|
||||
if (std.mem.eql(u8, a.alias, name)) {
|
||||
a.target = target;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (self.aliases.items.len >= max_write_aliases) {
|
||||
log.warn("clipboard write has too many aliases, ignoring", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
try self.aliases.append(alloc, .{
|
||||
.alias = try self.arena.allocator().dupe(u8, name),
|
||||
.target = target,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a committed transaction. All slices borrow the
|
||||
/// WriteState's memory and are valid until it is deinited.
|
||||
pub const Committed = struct {
|
||||
loc: clipboard.Location,
|
||||
id: []const u8,
|
||||
pw: []const u8,
|
||||
has_name: bool,
|
||||
truncated: bool,
|
||||
contents: []const Content,
|
||||
|
||||
pub fn deinit(self: *const Committed, alloc: Allocator) void {
|
||||
alloc.free(self.contents);
|
||||
}
|
||||
};
|
||||
|
||||
/// Commit the transaction (a wdata packet without a MIME type).
|
||||
/// The caller must use the result, call Committed.deinit, and then
|
||||
/// deinit this state.
|
||||
pub fn commit(
|
||||
self: *WriteState,
|
||||
alloc: Allocator,
|
||||
) error{OutOfMemory}!Committed {
|
||||
// Finalize the region receiving data.
|
||||
if (self.current) |idx| {
|
||||
const entry = &self.entries.items[idx];
|
||||
entry.len = self.spool.items.len - entry.start;
|
||||
self.current = null;
|
||||
}
|
||||
|
||||
// Resolve the final MIME map: entries in arrival order, then
|
||||
// aliases applied sequentially against the evolving map so
|
||||
// chained aliases work like kitty's dict iteration. An alias
|
||||
// whose target has no mapping is dropped; an alias colliding
|
||||
// with an existing name overwrites it.
|
||||
var contents: std.ArrayListUnmanaged(Content) = .empty;
|
||||
defer contents.deinit(alloc);
|
||||
try contents.ensureTotalCapacity(
|
||||
alloc,
|
||||
self.entries.items.len + self.aliases.items.len,
|
||||
);
|
||||
|
||||
for (self.entries.items) |*entry| {
|
||||
contents.appendAssumeCapacity(.{
|
||||
.mime = entry.mime,
|
||||
.data = self.spool.items[entry.start..][0..entry.len],
|
||||
});
|
||||
}
|
||||
|
||||
for (self.aliases.items) |*a| {
|
||||
const target: Content = target: {
|
||||
for (contents.items) |c| {
|
||||
if (std.mem.eql(u8, c.mime, a.target)) {
|
||||
break :target c;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
for (contents.items) |*c| {
|
||||
if (std.mem.eql(u8, c.mime, a.alias)) {
|
||||
c.data = target.data;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
contents.appendAssumeCapacity(.{
|
||||
.mime = a.alias,
|
||||
.data = target.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.loc = self.loc,
|
||||
.id = self.id,
|
||||
.pw = self.pw,
|
||||
.has_name = self.has_name,
|
||||
.truncated = self.truncated,
|
||||
.contents = try contents.toOwnedSlice(alloc),
|
||||
};
|
||||
}
|
||||
|
||||
fn logTruncatedOnce() void {
|
||||
// Log spam protection: this can be hit for every chunk of an
|
||||
// oversized write.
|
||||
const S = struct {
|
||||
var logged: bool = false;
|
||||
};
|
||||
if (!S.logged) {
|
||||
S.logged = true;
|
||||
log.warn(
|
||||
"clipboard write exceeds {} bytes, truncating",
|
||||
.{max_write_size},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "write: basic transaction" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write, .id = "42" };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqualStrings("42", committed.id);
|
||||
try testing.expect(committed.loc == .standard);
|
||||
try testing.expectEqual(@as(usize, 1), committed.contents.len);
|
||||
try testing.expectEqualStrings("text/plain", committed.contents[0].mime);
|
||||
try testing.expectEqualStrings("Ghostty", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: chunked data accumulates" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello"
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqualStrings("HelloWorld", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: multiple mimes in order" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a"
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 2), committed.contents.len);
|
||||
try testing.expectEqualStrings("text/plain", committed.contents[0].mime);
|
||||
try testing.expectEqualStrings("a", committed.contents[0].data);
|
||||
try testing.expectEqualStrings("text/html", committed.contents[1].mime);
|
||||
try testing.expectEqualStrings("b", committed.contents[1].data);
|
||||
}
|
||||
|
||||
test "write: reused mime overwrites" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a"
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b"
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "Yw=="); // "c"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 2), committed.contents.len);
|
||||
// Position preserved, data replaced.
|
||||
try testing.expectEqualStrings("text/plain", committed.contents[0].mime);
|
||||
try testing.expectEqualStrings("c", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: invalid base64 chunk is dropped, transaction continues" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello"
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "!!!bad!!!");
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqualStrings("HelloWorld", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: aliases resolve at commit" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty"
|
||||
|
||||
// Alias "TEXT UTF8_STRING" -> text/plain.
|
||||
const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" };
|
||||
try state.alias(alloc, &alias_meta, "VEVYVCBVVEY4X1NUUklORw==");
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 3), committed.contents.len);
|
||||
try testing.expectEqualStrings("TEXT", committed.contents[1].mime);
|
||||
try testing.expectEqualStrings("Ghostty", committed.contents[1].data);
|
||||
try testing.expectEqualStrings("UTF8_STRING", committed.contents[2].mime);
|
||||
try testing.expectEqualStrings("Ghostty", committed.contents[2].data);
|
||||
}
|
||||
|
||||
test "write: alias without data target is dropped" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
defer state.deinit(alloc);
|
||||
|
||||
const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" };
|
||||
try state.alias(alloc, &alias_meta, "VEVYVA=="); // "TEXT"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 0), committed.contents.len);
|
||||
}
|
||||
@@ -327,6 +327,14 @@ const entries: []const ModeEntry = &.{
|
||||
.{ .name = "report_color_scheme", .value = 2031 },
|
||||
.{ .name = "report_visibility", .value = 2033, .default_configurable = false },
|
||||
.{ .name = "in_band_size_reports", .value = 2048 },
|
||||
// Kitty clipboard protocol paste events. When set, a user-initiated
|
||||
// paste sends an unsolicited OSC 5522 targets listing with a
|
||||
// one-time password instead of pasting the text.
|
||||
// See https://sw.kovidgoyal.net/kitty/clipboard/
|
||||
//
|
||||
// Forcibly disabled for now since the functionality isn't exposed
|
||||
// yet to libghostty or any GUI apps.
|
||||
.{ .name = "kitty_paste_events", .value = 5522, .disabled = true },
|
||||
};
|
||||
|
||||
test {
|
||||
|
||||
@@ -22,6 +22,12 @@ pub const OSC = struct {
|
||||
/// The terminator that was used in case we need to send a response.
|
||||
terminator: Terminator,
|
||||
|
||||
pub const C = void;
|
||||
|
||||
pub fn cval(_: OSC) C {
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Decode an option from the metadata.
|
||||
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
|
||||
return key.read(self.metadata);
|
||||
|
||||
@@ -390,13 +390,13 @@ types:
|
||||
Each named instance exposes one bit from the little-endian integer.
|
||||
Arithmetic division is used instead of bitwise operations because the
|
||||
JavaScript target implements those operations with signed 32-bit values.
|
||||
All values remain exact because the registry occupies only 42 bits,
|
||||
All values remain exact because the registry occupies only 43 bits,
|
||||
within JavaScript's 53-bit safe integer range.
|
||||
seq:
|
||||
- id: raw
|
||||
type: u8
|
||||
valid:
|
||||
max: 4398046511103
|
||||
max: 8796093022207
|
||||
instances:
|
||||
disable_keyboard:
|
||||
value: (raw / 1) % 2 != 0
|
||||
@@ -482,6 +482,8 @@ types:
|
||||
value: (raw / 1099511627776) % 2 != 0
|
||||
in_band_size_reports:
|
||||
value: (raw / 2199023255552) % 2 != 0
|
||||
kitty_paste_events:
|
||||
value: (raw / 4398046511104) % 2 != 0
|
||||
|
||||
tab_stops:
|
||||
params:
|
||||
|
||||
@@ -206,7 +206,8 @@
|
||||
//! bit 39 report_color_scheme
|
||||
//! bit 40 report_visibility
|
||||
//! bit 41 in_band_size_reports
|
||||
//! bits 42-63 reserved, zero
|
||||
//! bit 42 kitty_paste_events
|
||||
//! bits 43-63 reserved, zero
|
||||
//! ```
|
||||
//!
|
||||
//! This is the packed field order of native `ModePacked`. Its layout is
|
||||
@@ -470,7 +471,7 @@ pub const Header = struct {
|
||||
try writer.writeByte(@intCast(@intFromEnum(self.mouse_shape)));
|
||||
try writer.writeByte(@intFromBool(self.password_input));
|
||||
|
||||
// Runtime, saved, and reset mode sets. ModePacked occupies 41 bits;
|
||||
// Runtime, saved, and reset mode sets. ModePacked occupies 43 bits;
|
||||
// its eight-byte wire slots zero-extend the native packed value.
|
||||
const mode_values = [_]terminal_modes.ModePacked{
|
||||
self.current_modes,
|
||||
@@ -1203,7 +1204,7 @@ const test_header_fixture = test_fixture.parse(
|
||||
|
||||
test "TERMINAL mode bit layout" {
|
||||
try std.testing.expectEqual(
|
||||
@as(usize, 42),
|
||||
@as(usize, 43),
|
||||
@bitSizeOf(terminal_modes.ModePacked),
|
||||
);
|
||||
|
||||
@@ -1212,8 +1213,8 @@ test "TERMINAL mode bit layout" {
|
||||
);
|
||||
first.disable_keyboard = true;
|
||||
try std.testing.expectEqual(
|
||||
@as(u42, 1) << 0,
|
||||
@as(u42, @bitCast(first)),
|
||||
@as(u43, 1) << 0,
|
||||
@as(u43, @bitCast(first)),
|
||||
);
|
||||
|
||||
var visibility: terminal_modes.ModePacked = std.mem.zeroes(
|
||||
@@ -1221,17 +1222,26 @@ test "TERMINAL mode bit layout" {
|
||||
);
|
||||
visibility.report_visibility = true;
|
||||
try std.testing.expectEqual(
|
||||
@as(u42, 1) << 40,
|
||||
@as(u42, @bitCast(visibility)),
|
||||
@as(u43, 1) << 40,
|
||||
@as(u43, @bitCast(visibility)),
|
||||
);
|
||||
|
||||
var size_reports: terminal_modes.ModePacked = std.mem.zeroes(
|
||||
terminal_modes.ModePacked,
|
||||
);
|
||||
size_reports.in_band_size_reports = true;
|
||||
try std.testing.expectEqual(
|
||||
@as(u43, 1) << 41,
|
||||
@as(u43, @bitCast(size_reports)),
|
||||
);
|
||||
|
||||
var last: terminal_modes.ModePacked = std.mem.zeroes(
|
||||
terminal_modes.ModePacked,
|
||||
);
|
||||
last.in_band_size_reports = true;
|
||||
last.kitty_paste_events = true;
|
||||
try std.testing.expectEqual(
|
||||
@as(u42, 1) << 41,
|
||||
@as(u42, @bitCast(last)),
|
||||
@as(u43, 1) << 42,
|
||||
@as(u43, @bitCast(last)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ pub const Action = union(Key) {
|
||||
kitty_color_report: kitty.color.OSC,
|
||||
color_operation: ColorOperation,
|
||||
semantic_prompt: SemanticPrompt,
|
||||
kitty_clipboard: KittyClipboard,
|
||||
|
||||
pub const Key = lib.Enum(
|
||||
lib.target,
|
||||
@@ -227,6 +228,7 @@ pub const Action = union(Key) {
|
||||
"kitty_color_report",
|
||||
"color_operation",
|
||||
"semantic_prompt",
|
||||
"kitty_clipboard",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -444,6 +446,8 @@ pub const Action = union(Key) {
|
||||
};
|
||||
|
||||
pub const SemanticPrompt = osc.Command.SemanticPrompt;
|
||||
|
||||
pub const KittyClipboard = osc.Command.KittyClipboardProtocol;
|
||||
};
|
||||
|
||||
/// Returns a type that can process a stream of tty control characters.
|
||||
@@ -2551,6 +2555,10 @@ pub fn Stream(comptime H: type) type {
|
||||
self.handler.vt(.progress_report, v);
|
||||
},
|
||||
|
||||
.kitty_clipboard_protocol => |v| {
|
||||
self.handler.vt(.kitty_clipboard, v);
|
||||
},
|
||||
|
||||
.conemu_sleep,
|
||||
.conemu_show_message_box,
|
||||
.conemu_change_tab_title,
|
||||
@@ -2561,7 +2569,6 @@ pub fn Stream(comptime H: type) type {
|
||||
.conemu_output_environment_variable,
|
||||
.conemu_run_process,
|
||||
.kitty_text_sizing,
|
||||
.kitty_clipboard_protocol,
|
||||
.kitty_dnd_protocol,
|
||||
.context_signal,
|
||||
=> {
|
||||
|
||||
@@ -398,6 +398,8 @@ pub const Handler = struct {
|
||||
// Have no terminal-modifying effect
|
||||
.title_push,
|
||||
.title_pop,
|
||||
// Unimplemented; the sequence is consumed and ignored.
|
||||
.kitty_clipboard,
|
||||
=> {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ pub const StreamHandler = struct {
|
||||
// Unimplemented
|
||||
.title_push,
|
||||
.title_pop,
|
||||
.kitty_clipboard,
|
||||
=> {},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user