terminal/osc: kitty notification parsing feedback

This commit is contained in:
Mitchell Hashimoto
2026-08-21 13:59:41 -07:00
parent 073bffcff4
commit ca9e5b1301
7 changed files with 188 additions and 99 deletions

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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| {

View 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);
}

View File

@@ -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;
}
};
}

View File

@@ -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");

View File

@@ -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);
}