mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 16:41:44 +00:00
terminal: Kitty Clipboard core logic (no apprt hookups yet) (#13962)
This adds all the core logic and tests for the full Kitty Clipboard protocol in the `src/terminal` package. This is purposefully shaped similarly to the way we organize Kitty graphics. There is an umbrella `clipboard.zig` and then a bunch of leaf zig files that cover: request parsing, response encoding, state management, etc. I think that worked really well for Kitty graphics so we're doing it here too. The core logic covers every part of the protocol: read and write. The only thing hooked up to the end user is a DECRQM for mode 5522 will return unset. And it can't be set currently (since it never works yet). Outside of that, nothing in this diff is actually used in the real binary. **AI usage:** Validation against the spec and Kitty impl, test writing and coverage validation, of course some code writing but within the broad organizational shape I defined. I went through and either rewrote or wrote all the comments myself plus this PR message.
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;
|
||||
|
||||
@@ -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