mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal/osc: kitty notification parsing feedback
This commit is contained in:
@@ -66,6 +66,7 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23,
|
||||
GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24,
|
||||
GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25,
|
||||
GHOSTTY_OSC_COMMAND_KITTY_DESKTOP_NOTIFICATION = 26,
|
||||
GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyOscCommandType;
|
||||
|
||||
|
||||
@@ -1000,6 +1000,7 @@ test "manifest uses public enum names" {
|
||||
try std.testing.expectEqual(@as(i64, 23), osc_values.get("KITTY_CLIPBOARD_PROTOCOL").?.integer);
|
||||
try std.testing.expectEqual(@as(i64, 24), osc_values.get("KITTY_DND_PROTOCOL").?.integer);
|
||||
try std.testing.expectEqual(@as(i64, 25), osc_values.get("CONTEXT_SIGNAL").?.integer);
|
||||
try std.testing.expectEqual(@as(i64, 26), osc_values.get("KITTY_DESKTOP_NOTIFICATION").?.integer);
|
||||
try std.testing.expect(osc_values.contains("TYPE_MAX_VALUE"));
|
||||
try std.testing.expect(!osc_values.contains("MAX_VALUE"));
|
||||
|
||||
|
||||
@@ -159,13 +159,13 @@ pub const Command = union(Key) {
|
||||
/// Kitty drag and drop protocol (OSC 72)
|
||||
kitty_dnd_protocol: KittyDndProtocol,
|
||||
|
||||
/// Kitty desktop notifications (OSC 99)
|
||||
kitty_desktop_notification: KittyDesktopNotification,
|
||||
|
||||
/// OSC 3008. Hierarchical context signalling (UAPI spec).
|
||||
/// https://uapi-group.org/specifications/specs/osc_context/
|
||||
context_signal: parsers.context_signal.Command,
|
||||
|
||||
/// Kitty desktop notifications (OSC 99)
|
||||
kitty_desktop_notification: KittyDesktopNotification,
|
||||
|
||||
pub const SemanticPrompt = parsers.semantic_prompt.Command;
|
||||
|
||||
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
|
||||
@@ -203,8 +203,8 @@ pub const Command = union(Key) {
|
||||
"kitty_text_sizing",
|
||||
"kitty_clipboard_protocol",
|
||||
"kitty_dnd_protocol",
|
||||
"kitty_desktop_notification",
|
||||
"context_signal",
|
||||
"kitty_desktop_notification",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -800,7 +800,7 @@ pub const Parser = struct {
|
||||
|
||||
.@"99",
|
||||
=> switch (c) {
|
||||
// OSC 99 can be up to 4096 bytes fully encoded.
|
||||
// OSC 99 encoded payloads can exceed the fixed buffer.
|
||||
';' => self.captureTrailing(.allocating),
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
@@ -911,7 +911,7 @@ test {
|
||||
|
||||
test "Parser allocating captures have a hard limit" {
|
||||
const testing = std.testing;
|
||||
const prefixes = [_][]const u8{ "52;", "66;", "72;", "5522;" };
|
||||
const prefixes = [_][]const u8{ "52;", "66;", "72;", "99;", "5522;" };
|
||||
const limit = Parser.MAX_BUF + 1;
|
||||
|
||||
for (prefixes) |prefix| {
|
||||
|
||||
94
src/terminal/osc/kitty_metadata.zig
Normal file
94
src/terminal/osc/kitty_metadata.zig
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Helpers for parsing metadata shared by Kitty OSC protocols.
|
||||
//!
|
||||
//! Kitty OSC 99 and OSC 5522 encode metadata as colon-separated `key=value`
|
||||
//! fields. The iterator in this module lazily searches that metadata for one
|
||||
//! key, preserving the order of repeated values without allocating.
|
||||
//!
|
||||
//! Parsing is intentionally tolerant. Whitespace around keys and values is
|
||||
//! trimmed, while malformed fields, non-matching keys, and invalid values are
|
||||
//! skipped. Returned values are slices of the original metadata and remain
|
||||
//! valid only as long as that input remains valid.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Return an iterator over values whose key exactly matches `key`.
|
||||
///
|
||||
/// If `valid_value_characters` is non-null, every byte in a returned value must
|
||||
/// appear in that character set. Passing null disables value validation. Both
|
||||
/// arguments are comptime-known so each protocol can specialize the iterator
|
||||
/// for its metadata grammar without storing a key or validator at runtime.
|
||||
pub fn ValueIterator(
|
||||
comptime key: []const u8,
|
||||
comptime valid_value_characters: ?[]const u8,
|
||||
) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
metadata: []const u8,
|
||||
pos: usize,
|
||||
|
||||
/// Initialize an iterator borrowing `metadata`.
|
||||
pub fn init(metadata: []const u8) Self {
|
||||
return .{
|
||||
.metadata = metadata,
|
||||
.pos = 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return the next valid matching value, or null when none remain.
|
||||
/// The returned slice borrows the metadata passed to `init`.
|
||||
pub fn next(self: *Self) ?[]const u8 {
|
||||
while (self.pos < self.metadata.len) {
|
||||
const end = std.mem.indexOfScalarPos(
|
||||
u8,
|
||||
self.metadata,
|
||||
self.pos,
|
||||
':',
|
||||
) orelse self.metadata.len;
|
||||
const field = self.metadata[self.pos..end];
|
||||
self.pos = if (end < self.metadata.len) end + 1 else end;
|
||||
|
||||
const equals = std.mem.indexOfScalar(u8, field, '=') orelse
|
||||
continue;
|
||||
const field_key = std.mem.trim(
|
||||
u8,
|
||||
field[0..equals],
|
||||
&std.ascii.whitespace,
|
||||
);
|
||||
if (!std.mem.eql(u8, field_key, key)) continue;
|
||||
|
||||
const value = std.mem.trim(
|
||||
u8,
|
||||
field[equals + 1 ..],
|
||||
&std.ascii.whitespace,
|
||||
);
|
||||
if (valid_value_characters) |valid| {
|
||||
if (std.mem.indexOfNone(u8, value, valid) != null) continue;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test "ValueIterator skips malformed and prefix-matching keys" {
|
||||
const testing = std.testing;
|
||||
var it: ValueIterator("id", null) = .init(
|
||||
"id-extra=wrong:id: id = first :id=second",
|
||||
);
|
||||
|
||||
try testing.expectEqualStrings("first", it.next().?);
|
||||
try testing.expectEqualStrings("second", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
test "ValueIterator skips values containing disallowed characters" {
|
||||
const testing = std.testing;
|
||||
var it: ValueIterator("i", "abc") = .init("i=a?:i=abc");
|
||||
|
||||
try testing.expectEqualStrings("abc", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// Used to iterate over matching key/values in the metadata
|
||||
pub fn Iterator(comptime Option: type, comptime isValidMetadataValue: fn ([]const u8) bool, comptime key: Option) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
metadata: []const u8,
|
||||
pos: usize,
|
||||
|
||||
pub fn init(metadata: []const u8) Self {
|
||||
return .{
|
||||
.metadata = metadata,
|
||||
.pos = 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return the value of the next matching key. The value is guaranteed
|
||||
/// to be `null` or a valid metadata value.
|
||||
pub fn next(self: *Self) ?[]const u8 {
|
||||
// bail if we are out of metadata
|
||||
if (self.pos >= self.metadata.len) return null;
|
||||
while (self.pos < self.metadata.len) {
|
||||
// skip any whitespace
|
||||
while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1;
|
||||
// bail if we are out of metadata
|
||||
if (self.pos >= self.metadata.len) return null;
|
||||
if (!std.mem.startsWith(u8, self.metadata[self.pos..], @tagName(key))) {
|
||||
// this isn't the key we are looking for, skip to the next option, or bail if
|
||||
// there is no next option
|
||||
self.pos = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse {
|
||||
self.pos = self.metadata.len;
|
||||
return null;
|
||||
};
|
||||
self.pos += 1;
|
||||
continue;
|
||||
}
|
||||
// skip past the key
|
||||
self.pos += @tagName(key).len;
|
||||
// skip any whitespace
|
||||
while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1;
|
||||
// bail if we are out of metadata
|
||||
if (self.pos >= self.metadata.len) return null;
|
||||
// a valid option has an '='
|
||||
if (self.metadata[self.pos] != '=') return null;
|
||||
// the end of the value is bounded by a ':' or the end of the metadata
|
||||
const end = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse self.metadata.len;
|
||||
const start = self.pos + 1;
|
||||
self.pos = end + 1;
|
||||
// strip any leading or trailing whitespace
|
||||
const value = std.mem.trim(u8, self.metadata[start..end], &std.ascii.whitespace);
|
||||
// if this is not a valid value, skip it
|
||||
if (!@call(.always_inline, isValidMetadataValue, .{value})) continue;
|
||||
// return the value
|
||||
return value;
|
||||
}
|
||||
// the key was not found
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const assert = @import("../../../quirks.zig").inlineAssert;
|
||||
const Parser = @import("../../osc.zig").Parser;
|
||||
const Command = @import("../../osc.zig").Command;
|
||||
const Terminator = @import("../../osc.zig").Terminator;
|
||||
const Iterator = @import("../lib.zig").Iterator;
|
||||
const kitty_metadata = @import("../kitty_metadata.zig");
|
||||
const encoding = @import("../encoding.zig");
|
||||
|
||||
const log = std.log.scoped(.kitty_clipboard_protocol);
|
||||
@@ -97,7 +97,7 @@ pub const Option = enum {
|
||||
comptime key: Option,
|
||||
metadata: []const u8,
|
||||
) ?key.Type() {
|
||||
var it: Iterator(Option, isValidMetadataValue, key) = .init(metadata);
|
||||
var it: kitty_metadata.ValueIterator(@tagName(key), null) = .init(metadata);
|
||||
const value = it.next() orelse return null;
|
||||
|
||||
// return the parsed value
|
||||
@@ -127,10 +127,6 @@ fn parseIdentifier(str: []const u8) ?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
fn isValidMetadataValue(_: []const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
|
||||
assert(parser.state == .@"5522");
|
||||
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/desktop-notifications/
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("terminal_options");
|
||||
|
||||
const assert = @import("../../../quirks.zig").inlineAssert;
|
||||
|
||||
const Parser = @import("../../osc.zig").Parser;
|
||||
const Command = @import("../../osc.zig").Command;
|
||||
const Terminator = @import("../../osc.zig").Terminator;
|
||||
const Iterator = @import("../lib.zig").Iterator;
|
||||
const kitty_metadata = @import("../kitty_metadata.zig");
|
||||
const encoding = @import("../encoding.zig");
|
||||
const lib = @import("../../../lib/main.zig");
|
||||
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
|
||||
|
||||
const log = std.log.scoped(.kitty_desktop_notification);
|
||||
|
||||
pub const MAX_PLAIN_PAYLOAD_BYTES = 2048;
|
||||
pub const MAX_ENCODED_PAYLOAD_BYTES = 4096;
|
||||
|
||||
pub const OSC = struct {
|
||||
/// The raw metadata that was received. It can be parsed by using the `readOption` method.
|
||||
metadata: []const u8,
|
||||
@@ -137,11 +137,17 @@ pub const Option = enum {
|
||||
.f => ?[]const u8,
|
||||
.g => ?[]const u8,
|
||||
.i => ?[]const u8,
|
||||
.n => Iterator(Option, isValidMetadataValue, .n),
|
||||
.n => kitty_metadata.ValueIterator(
|
||||
@tagName(key),
|
||||
valid_metadata_value_characters,
|
||||
),
|
||||
.o => Occasion,
|
||||
.p => Payload,
|
||||
.s => []const u8,
|
||||
.t => Iterator(Option, isValidMetadataValue, .t),
|
||||
.t => kitty_metadata.ValueIterator(
|
||||
@tagName(key),
|
||||
valid_metadata_value_characters,
|
||||
),
|
||||
.u => Urgency,
|
||||
.w => i32,
|
||||
};
|
||||
@@ -168,13 +174,16 @@ pub const Option = enum {
|
||||
|
||||
/// Read the option value from the raw metadata string.
|
||||
///
|
||||
/// Any errors in the raw string will return null since the OSC 99
|
||||
/// specification says to ignore unknown or malformed options.
|
||||
/// Unknown and malformed values are ignored. Optional values return null;
|
||||
/// all other values return the protocol default.
|
||||
pub fn read(
|
||||
comptime key: Option,
|
||||
metadata: []const u8,
|
||||
) key.Type() {
|
||||
var it: Iterator(Option, isValidMetadataValue, key) = switch (key) {
|
||||
var it: kitty_metadata.ValueIterator(
|
||||
@tagName(key),
|
||||
valid_metadata_value_characters,
|
||||
) = switch (key) {
|
||||
.t, .n => return .init(metadata),
|
||||
else => .init(metadata),
|
||||
};
|
||||
@@ -222,7 +231,7 @@ fn parseBool(str: []const u8) ?bool {
|
||||
/// This is similar to the packed struct parser used in the configs. The
|
||||
/// differences are that a literal `true` or `false` value does not turn on/off
|
||||
/// all the values, and the negation prefix is `-` not `no-`.
|
||||
pub fn parsePackedStruct(comptime T: type, str: []const u8) T {
|
||||
fn parsePackedStruct(comptime T: type, str: []const u8) T {
|
||||
const info = @typeInfo(T).@"struct";
|
||||
comptime assert(info.layout == .@"packed");
|
||||
|
||||
@@ -262,14 +271,10 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T {
|
||||
/// against the spec but is needed since Base64 encoded values (with padding)
|
||||
/// are valid for some options. Including `?` is technically against the spec
|
||||
/// but is needed since it is a valid value for the `p` option.
|
||||
const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/.,(){}[]*&^%$#@!`~=?";
|
||||
|
||||
fn isValidMetadataValue(str: []const u8) bool {
|
||||
return std.mem.indexOfNone(u8, str, valid_metadata_value_characters) == null;
|
||||
}
|
||||
const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/,(){}[]*&^%$#@!`~=?";
|
||||
|
||||
/// Characters that are valid in identifiers.
|
||||
const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+";
|
||||
const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
|
||||
|
||||
fn isValidIdentifier(str: []const u8) bool {
|
||||
return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null;
|
||||
@@ -299,7 +304,20 @@ pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
|
||||
const metadata = data[0..payload_start];
|
||||
const payload = data[payload_start + 1 .. data.len];
|
||||
|
||||
// Payload has to be a URL-safe UTF-8 string.
|
||||
const max_payload_bytes: usize = if (Option.e.read(metadata))
|
||||
MAX_ENCODED_PAYLOAD_BYTES
|
||||
else
|
||||
MAX_PLAIN_PAYLOAD_BYTES;
|
||||
if (payload.len > max_payload_bytes) {
|
||||
log.warn(
|
||||
"payload is too large: size={d} max={d}",
|
||||
.{ payload.len, max_payload_bytes },
|
||||
);
|
||||
parser.state = .invalid;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Payload has to be an escape-code-safe UTF-8 string.
|
||||
if (!encoding.isSafeUtf8(payload)) {
|
||||
log.warn("payload is not escape code safe UTF-8", .{});
|
||||
parser.state = .invalid;
|
||||
@@ -386,18 +404,58 @@ test "OSC 99: empty metadata with payload" {
|
||||
try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w));
|
||||
}
|
||||
|
||||
test "OSC 99: payload size limits" {
|
||||
const testing = std.testing;
|
||||
const cases = [_]struct {
|
||||
metadata: []const u8,
|
||||
payload_size: usize,
|
||||
valid: bool,
|
||||
}{
|
||||
.{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES, .valid = true },
|
||||
.{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES + 1, .valid = false },
|
||||
.{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES, .valid = true },
|
||||
.{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES + 1, .valid = false },
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
for ("99;") |ch| p.next(ch);
|
||||
for (case.metadata) |ch| p.next(ch);
|
||||
p.next(';');
|
||||
for (0..case.payload_size) |_| p.next('a');
|
||||
|
||||
try testing.expectEqual(case.valid, p.end('\x1b') != null);
|
||||
}
|
||||
}
|
||||
|
||||
test "OSC 99: unknown prefix does not hide dotted identifier" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(null);
|
||||
const input = "99;invalid=wrong:i=org.ghostty;payload";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expectEqualStrings(
|
||||
"org.ghostty",
|
||||
cmd.kitty_desktop_notification.readOption(.i).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "OSC 99: single parameter i" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(null);
|
||||
|
||||
const input = "99;i=bobr;kurwa";
|
||||
const input = "99;i=bobr;payload";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_desktop_notification);
|
||||
try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?);
|
||||
try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload);
|
||||
try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload);
|
||||
}
|
||||
|
||||
test "OSC 99: repeated parameter i" {
|
||||
@@ -405,13 +463,13 @@ test "OSC 99: repeated parameter i" {
|
||||
|
||||
var p: Parser = .init(null);
|
||||
|
||||
const input = "99;i=bobr:i=foobar;kurwa";
|
||||
const input = "99;i=bobr:i=foobar;payload";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_desktop_notification);
|
||||
try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?);
|
||||
try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload);
|
||||
try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload);
|
||||
}
|
||||
|
||||
test "OSC 99: multiple types" {
|
||||
@@ -419,16 +477,16 @@ test "OSC 99: multiple types" {
|
||||
|
||||
var p: Parser = .init(null);
|
||||
|
||||
const input = "99;t=bobr: t = kurwa : t = ghostty ;foobar";
|
||||
const input = "99;t=mail: t = chat : t = alert ;notification";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_desktop_notification);
|
||||
try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload);
|
||||
try testing.expectEqualStrings("notification", cmd.kitty_desktop_notification.payload);
|
||||
var it = cmd.kitty_desktop_notification.readOption(.t);
|
||||
try testing.expectEqualStrings("bobr", it.next().?);
|
||||
try testing.expectEqualStrings("kurwa", it.next().?);
|
||||
try testing.expectEqualStrings("ghostty", it.next().?);
|
||||
try testing.expectEqualStrings("mail", it.next().?);
|
||||
try testing.expectEqualStrings("chat", it.next().?);
|
||||
try testing.expectEqualStrings("alert", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user