diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c index c40f8fa88..bc5ece06d 100644 --- a/example/c-vt-effects/src/main.c +++ b/example/c-vt-effects/src/main.c @@ -70,6 +70,32 @@ GhosttyClipboardWriteResult on_clipboard_write( } //! [effects-clipboard-write] +//! [effects-unknown-sequence] +void on_unknown_sequence( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalUnknownSequence* sequence) { + (void)terminal; + (void)userdata; + + switch (sequence->tag) { + case GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC: { + const GhosttyTerminalUnknownStringSequence* apc = &sequence->value.apc; + printf(" unknown APC (truncated=%s, content=%zu bytes): ", + apc->truncated ? "yes" : "no", + apc->content.len); + if (apc->content.len > 0) { + fwrite(apc->content.ptr, 1, apc->content.len, stdout); + } + printf("\n"); + break; + } + default: + break; + } +} +//! [effects-unknown-sequence] + //! [effects-register] int main() { // Create a terminal @@ -92,6 +118,14 @@ int main() { (const void *)on_title_changed); ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void *)on_clipboard_write); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE, + (const void *)on_unknown_sequence); + + // Unknown sequence capture is independently bounded and disabled by + // default. This limit will apply to every supported unknown sequence type. + size_t unknown_max_bytes = 256; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES, + &unknown_max_bytes); // Feed VT data that triggers effects: @@ -120,7 +154,13 @@ int main() { ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq, strlen(clipboard_seq)); - // 5. Another bell to show the counter increments + // 5. Unsupported APC sequence + printf("Sending unknown APC:\n"); + const char* unknown_apc = "\x1B_private-command;payload\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc, + strlen(unknown_apc)); + + // 6. Another bell to show the counter increments printf("Sending another BEL:\n"); ghostty_terminal_vt_write(terminal, &bel, 1); diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 2c783974b..8a914a47d 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -97,6 +97,7 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | * | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 | * | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 | + * | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -110,6 +111,9 @@ extern "C" { * ### Defining a clipboard_write callback * @snippet c-vt-effects/src/main.c effects-clipboard-write * + * ### Defining an unknown_sequence callback + * @snippet c-vt-effects/src/main.c effects-unknown-sequence + * * ### Registering effects and processing VT data * @snippet c-vt-effects/src/main.c effects-register * @@ -331,6 +335,88 @@ typedef struct { typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); +/** + * Unsupported terminal sequence tags. + * + * Only APC sequences are currently reported. Additional sequence types may + * be added without changing the callback shape. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Application Program Command (APC). */ + GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC = 0, + GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalUnknownSequenceTag; + +/** + * An unsupported string terminal sequence. + * + * The content is borrowed and valid only for the callback duration. It + * contains the bytes between the sequence introducer and terminator, may + * contain arbitrary binary data, and is not null-terminated. + * + * @ingroup terminal + */ +typedef struct { + /** Whether content was shortened by the byte limit or allocation failure. */ + bool truncated; + + /** Retained sequence content. */ + GhosttyString content; +} GhosttyTerminalUnknownStringSequence; + +/** + * Unsupported terminal sequence value. + * + * @ingroup terminal + */ +typedef union { + /** Application Program Command (APC). */ + GhosttyTerminalUnknownStringSequence apc; + + /** + * Padding for ABI compatibility. Do not use. + * + * 128 bytes leaves room for future structured sequence payloads, such as + * CSI with borrowed parameter, separator, and intermediate arrays, without + * changing the tagged union's ABI. + */ + uint64_t _padding[16]; +} GhosttyTerminalUnknownSequenceValue; + +/** + * An unsupported terminal sequence. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalUnknownSequenceTag tag; + GhosttyTerminalUnknownSequenceValue value; +} GhosttyTerminalUnknownSequence; + +/** + * Callback function type for unsupported terminal sequences. + * + * Called synchronously for normally terminated sequences whose identifier is + * not supported by the active terminal handler. Aborted sequences, malformed + * recognized commands, and explicitly disabled known protocols are ignored. + * + * Capture must also be enabled with a nonzero + * GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES value. Installing this callback alone + * does not retain sequence content or allocate memory. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param sequence Borrowed unsupported sequence + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalUnknownSequenceFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalUnknownSequence* sequence); + /** * Clipboard destination for a clipboard write. * @@ -1103,6 +1189,27 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyTerminalModeConfig* */ GHOSTTY_TERMINAL_OPT_MODE = 34, + + /** + * Callback invoked for unsupported terminal sequence identifiers. Set to + * NULL to ignore unsupported sequences. Capture must also be enabled with + * GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES. + * + * Input type: GhosttyTerminalUnknownSequenceFn + */ + GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE = 35, + + /** + * Set the maximum content bytes retained for each unsupported terminal + * sequence. A NULL value pointer or zero disables capture and prevents + * unknown-sequence callbacks. + * + * When this limit is hit, the unknown sequence callback will still + * be invoked but `truncated` will be set to true. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES = 36, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/benchmark/ApcParser.zig b/src/benchmark/ApcParser.zig index 84378f1a2..9efdc4fbf 100644 --- a/src/benchmark/ApcParser.zig +++ b/src/benchmark/ApcParser.zig @@ -52,10 +52,10 @@ const Handler = struct { .apc_start => self.apc.start(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), - .apc_end => if (self.apc.end()) |cmd| { - var c = cmd; - std.mem.doNotOptimizeAway(&c); - c.deinit(self.alloc); + .apc_end => if (self.apc.end()) |result| { + var r = result; + std.mem.doNotOptimizeAway(&r); + r.deinit(self.alloc); }, else => {}, } diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ab04d5c4a..c9edd20b0 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -80,6 +80,7 @@ pub const Terminal = terminal.Terminal; pub const TerminalStream = terminal.TerminalStream; pub const Stream = terminal.Stream; pub const StreamAction = terminal.StreamAction; +pub const UnknownSequence = terminal.UnknownSequence; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 3029fbbc1..0f819534f 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -13,6 +13,10 @@ const log = std.log.scoped(.terminal_apc); pub const Handler = struct { state: State = .inactive, + /// Maximum content bytes retained for unsupported APC identifiers. Zero + /// drops and ignores unknown APC values. + unknown_max_bytes: usize = 0, + /// Maximum bytes each APC protocol can buffer. This is to prevent /// malicious input from causing us to allocate too much memory. /// If you want to be lazy and set a single value for all protocols, @@ -23,8 +27,7 @@ pub const Handler = struct { }), /// Protocols recognized by this APC handler. When a protocol is absent, - /// matching APC sequences are ignored so callers see the same behavior as - /// an unsupported protocol: no command execution and no response. + /// matching APC sequences are ignored and are not reported as unknown. enabled: std.EnumSet(Protocol) = .initFull(), pub fn deinit(self: *Handler) void { @@ -50,51 +53,70 @@ pub const Handler = struct { // recognize it so there is no need to store the data in memory. .ignore => return, + // Unsupported APC content is retained only when enabled. + .unknown => |*unknown| unknown.append(&.{byte}), + // We identify the APC command by the first byte. .identify => |*id| id: { // Kitty graphics is detected immediately on the `G` byte, // since commands begin immediately after with no termination // character after the 'G'. - if (comptime build_options.kitty_graphics) { - if (id.len == 0 and - byte == 'G' and - self.enabled.contains(.kitty)) - { - self.state = .{ .kitty = .init( - alloc, - self.max_bytes.get(.kitty) orelse - Protocol.defaultMaxBytes(.kitty), - ) }; - break :id; + if (id.len == 0 and byte == 'G') { + if (comptime build_options.kitty_graphics) { + if (self.enabled.contains(.kitty)) { + self.state = .{ .kitty = .init( + alloc, + self.max_bytes.get(.kitty) orelse + Protocol.defaultMaxBytes(.kitty), + ) }; + } else { + self.state = .ignore; + } + } else { + self.state = .ignore; } + break :id; } // If we hit `;` then identify... if (byte == ';') { const str = id.buf[0..id.len]; - if (std.mem.eql(u8, str, "25a1") and - self.enabled.contains(.glyph)) - { - self.state = .{ .glyph = .init( - alloc, - self.max_bytes.get(.glyph) orelse - Protocol.defaultMaxBytes(.glyph), - ) }; + if (std.mem.eql(u8, str, glyph.identifier)) { + if (self.enabled.contains(.glyph)) { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } } else { - self.state = .ignore; + self.beginUnknown(alloc, str, &.{byte}); } break :id; } - // If we're out of space to buffer then we're done. + // If we're out of identification space, the identifier is + // unsupported. Preserve the buffered prefix before replacing + // the identify union state. if (id.len >= id.buf.len) { - self.state = .ignore; + self.beginUnknown(alloc, id.buf[0..id.len], &.{byte}); break :id; } + const expected_idx: usize = id.len; id.buf[id.len] = byte; id.len += 1; + + // Once the buffered input is no longer a prefix of a known + // protocol, it is an unsupported identifier. + if (self.unknown_max_bytes > 0 and + byte != glyph.identifier[expected_idx]) + { + self.beginUnknown(alloc, id.buf[0..id.len], &.{}); + } }, .kitty => |*p| if (comptime build_options.kitty_graphics) { @@ -113,6 +135,27 @@ pub const Handler = struct { } } + /// Transition from protocol identification to bounded unknown capture. + fn beginUnknown( + self: *Handler, + alloc: Allocator, + prefix: []const u8, + suffix: []const u8, + ) void { + const max_bytes = self.unknown_max_bytes; + if (max_bytes == 0) { + self.state = .ignore; + return; + } + + // Build the replacement before overwriting identify because prefix + // points into that union field. + var unknown: UnknownBuilder = .init(alloc, max_bytes); + unknown.append(prefix); + unknown.append(suffix); + self.state = .{ .unknown = unknown }; + } + /// Feed a slice of bytes to the handler. This is equivalent to /// calling feed for each byte in order, but protocol payload bytes /// are passed through in bulk so large payloads (e.g. Kitty graphics @@ -126,6 +169,12 @@ pub const Handler = struct { // We're ignoring this APC command; drop the whole slice. .ignore => return, + // We're capturing an unknown APC command, so store it. + .unknown => |*unknown| { + unknown.append(rem); + return; + }, + // Identification consumes at most a few bytes; step // through them one at a time until the state changes. .identify => { @@ -154,6 +203,8 @@ pub const Handler = struct { } } + /// Complete the current APC. The caller owns a returned result and must + /// call `Command.deinit` with the allocator used while feeding the APC. pub fn end(self: *Handler) ?Command { defer { self.state.deinit(); @@ -163,6 +214,7 @@ pub const Handler = struct { return switch (self.state) { .inactive => unreachable, .ignore, .identify => null, + .unknown => |*unknown| .{ .unknown = unknown.toOwned() }, .kitty => |*p| kitty: { if (comptime !build_options.kitty_graphics) unreachable; @@ -207,7 +259,7 @@ pub const State = union(enum) { /// identify: struct { len: u3 = 0, - buf: [4]u8 = undefined, + buf: [glyph.identifier.len]u8 = undefined, }, /// Kitty graphics protocol @@ -219,9 +271,15 @@ pub const State = union(enum) { /// Glyph protocol glyph: glyph.CommandParser, + /// An unsupported APC retained for the optional unknown callback. + /// Keep this after recognized protocol states so their tag values and + /// generated dispatch stay stable when unknown capture is unused. + unknown: UnknownBuilder, + pub fn deinit(self: *State) void { switch (self.*) { .inactive, .ignore, .identify => {}, + .unknown => |*v| v.deinit(), .glyph => |*v| v.deinit(), .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() @@ -231,6 +289,95 @@ pub const State = union(enum) { } }; +/// UnknownBuilder is responsible for accumulating bytes for an +/// unidentified APC command if unknown capture is enabled. +const UnknownBuilder = struct { + data: std.ArrayList(u8) = .empty, + alloc: Allocator, + max_bytes: usize, + truncated: bool = false, + + fn init(alloc: Allocator, max_bytes: usize) UnknownBuilder { + return .{ + .alloc = alloc, + .max_bytes = max_bytes, + }; + } + + fn deinit(self: *UnknownBuilder) void { + self.data.deinit(self.alloc); + self.data = .empty; + } + + // Append some bytes to the unknown capture. This flags as truncated + // if allocation fails or we reach our byte limit, therefore + // it can't fail. + fn append(self: *UnknownBuilder, bytes: []const u8) void { + if (bytes.len == 0) return; + const current = self.data.items.len; + + // Determine how many bytes we can store in this append and + // if it is less than our input, then we have to note we're + // truncating. + const retained = @min(bytes.len, self.max_bytes -| current); + if (retained < bytes.len) self.truncated = true; + + // If we require more bytes than our capacity allows then we + // need to grow. + const required = current + retained; + if (required > self.data.capacity) { + const capacity = @min( + self.max_bytes, + @max(required, @max(self.data.capacity *| 2, 1)), + ); + self.data.ensureTotalCapacityPrecise( + self.alloc, + capacity, + ) catch { + self.truncated = true; + return; + }; + } + + self.data.appendSliceAssumeCapacity(bytes[0..retained]); + } + + /// Convert the current capture state to an Unknown where allocator + /// ownership shifts to Unknown. Removes any accumulated unknown + /// capture in this struct. + /// + /// This can't fail because if there is an allocator issue we return + /// an empty truncate-flagged Unknown. + fn toOwned(self: *UnknownBuilder) Unknown { + // toOwnedSlice allows us to reuse data but this makes error + // handling a little simpler. + const content = self.data.toOwnedSlice(self.alloc) catch { + self.data.deinit(self.alloc); + self.data = .empty; + return .{ + .content = self.data.items, + .truncated = true, + }; + }; + + return .{ + .content = content, + .truncated = self.truncated, + }; + } +}; + +/// An unsupported APC returned by `Handler.end`. +pub const Unknown = struct { + content: []u8, + truncated: bool, + + pub fn deinit(self: *Unknown, alloc: Allocator) void { + if (self.content.len > 0) alloc.free(self.content); + self.* = undefined; + } +}; + /// Possible APC command types. pub const Protocol = enum { kitty, @@ -261,14 +408,15 @@ pub const Protocol = enum { } }; -/// Possible APC commands. -pub const Command = union(Protocol) { +/// A recognized or unsupported APC command. +pub const Command = union(enum) { kitty: if (build_options.kitty_graphics) kitty_gfx.Command else void, glyph: glyph.Request, + unknown: Unknown, pub fn deinit(self: *Command, alloc: Allocator) void { switch (self.*) { @@ -278,6 +426,7 @@ pub const Command = union(Protocol) { unreachable, .glyph => |*v| v.deinit(alloc), + .unknown => |*v| v.deinit(alloc), } } }; @@ -292,6 +441,64 @@ test "unknown APC command" { try testing.expect(h.end() == null); } +test "capture unknown APC command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .unknown_max_bytes = 5 }; + defer h.deinit(); + h.start(); + h.feedSlice(alloc, "abcd;payload"); + + var result = h.end().?; + defer result.deinit(alloc); + const unknown = &result.unknown; + try testing.expectEqualStrings("abcd;", unknown.content); + try testing.expect(unknown.truncated); +} + +test "capture short unknown APC command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .unknown_max_bytes = 16 }; + defer h.deinit(); + h.start(); + h.feed(alloc, 'X'); + + var result = h.end().?; + const unknown = &result.unknown; + try testing.expectEqualStrings("X", unknown.content); + try testing.expect(!unknown.truncated); + result.deinit(alloc); + + h.unknown_max_bytes = 1; + h.start(); + h.feedSlice(alloc, "XYZ"); + result = h.end().?; + const truncated = &result.unknown; + try testing.expectEqualStrings("X", truncated.content); + try testing.expect(truncated.truncated); + result.deinit(alloc); +} + +test "disabled known APC protocol is not unknown" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .unknown_max_bytes = 64 }; + defer h.deinit(); + h.enable(.glyph, false); + h.start(); + h.feedSlice(alloc, "25a1;q;cp=E0A0"); + try testing.expect(h.end() == null); + + // An incomplete known protocol identifier is malformed, not unknown. + h.start(); + h.feedSlice(alloc, "25a"); + try testing.expect(h.end() == null); +} + test "garbage Kitty command" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -374,9 +581,9 @@ test "valid Kitty command" { const input = "Gf=24,s=10,v=20,hello=world"; for (input) |c| h.feed(alloc, c); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .kitty); } test "identify with unrecognized command" { @@ -436,10 +643,10 @@ test "valid glyph command" { h.start(); for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .glyph); - try testing.expect(cmd.glyph == .query); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .glyph); + try testing.expect(result.glyph == .query); } test "feedSlice valid Kitty command" { @@ -452,12 +659,12 @@ test "feedSlice valid Kitty command" { h.start(); h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8="); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .kitty); // The payload is base64-decoded by the parser on completion. - try testing.expectEqualStrings("hello", cmd.kitty.data); + try testing.expectEqualStrings("hello", result.kitty.data); } test "feedSlice identify split across slices" { @@ -472,12 +679,12 @@ test "feedSlice identify split across slices" { h.feedSlice(alloc, "f=24,s=10,"); h.feedSlice(alloc, "v=20;aGVsbG8="); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .kitty); // The payload is base64-decoded by the parser on completion. - try testing.expectEqualStrings("hello", cmd.kitty.data); + try testing.expectEqualStrings("hello", result.kitty.data); } test "feedSlice unknown APC command is ignored" { @@ -500,10 +707,10 @@ test "feedSlice valid glyph command" { h.start(); h.feedSlice(alloc, "25a1;q;cp=E0A0"); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .glyph); - try testing.expect(cmd.glyph == .query); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .glyph); + try testing.expect(result.glyph == .query); } test "feedSlice kitty max bytes exceeded" { diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 727e1ab4d..13454a3f3 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -150,6 +150,9 @@ const std = @import("std"); +/// APC identifier for the glyph protocol. +pub const identifier = "25a1"; + pub const request = @import("glyph/request.zig"); pub const response = @import("glyph/response.zig"); pub const execute = @import("glyph/execute.zig").execute; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 63c996c0a..52619bc58 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -148,6 +148,36 @@ pub const ProgressReport = extern struct { progress: i8, }; +/// A borrowed unsupported string sequence. +/// +/// C: GhosttyTerminalUnknownStringSequence +pub const UnknownStringSequence = extern struct { + truncated: bool, + content: lib.String, +}; + +/// An unsupported terminal sequence reported to the C callback. +/// +/// C: GhosttyTerminalUnknownSequence +pub const UnknownSequence = union(Tag) { + apc: UnknownStringSequence, + + /// C: GhosttyTerminalUnknownSequenceTag + pub const Tag = lib.Enum(lib.target, &.{"apc"}); + + const c_union = lib.TaggedUnion( + lib.target, + @This(), + // A future borrowed CSI payload may need parameter, separator, and + // intermediate arrays. Reserve 128 bytes so that representation and + // other structured sequence types can be added without an ABI break. + [16]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; +}; + /// A terminal mode and boolean value used for mode configuration. /// /// C: GhosttyTerminalModeConfig @@ -161,9 +191,10 @@ pub const ModeConfig = extern struct { } }; -/// C callback state for terminal effects. Trampolines are always -/// installed on the stream handler; they check these fields and -/// no-op when the corresponding callback is null. +/// C callback state for terminal effects. Most trampolines are always +/// installed on the stream handler; they check these fields and no-op when +/// the corresponding callback is null. The unknown-sequence trampoline is +/// installed dynamically to preserve its null fast path. const Effects = struct { userdata: ?*anyopaque = null, write_pty: ?WritePtyFn = null, @@ -178,6 +209,7 @@ const Effects = struct { progress_report: ?ProgressReportFn = null, size_cb: ?SizeFn = null, clipboard_write: ?ClipboardWriteFn = null, + unknown_sequence: ?UnknownSequenceFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -225,6 +257,10 @@ const Effects = struct { /// C function pointer type for the progress_report callback. pub const ProgressReportFn = *const fn (Terminal, ?*anyopaque, *const ProgressReport) callconv(lib.calling_conv) void; + /// C function pointer type for the unknown_sequence callback. The request + /// and its content are borrowed for the callback duration. + pub const UnknownSequenceFn = *const fn (Terminal, ?*anyopaque, *const UnknownSequence.C) callconv(lib.calling_conv) void; + /// C function pointer type for the size callback. /// Returns true and fills out_size if size is available, /// or returns false to silently ignore the query. @@ -408,6 +444,26 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata, &c_report); } + fn unknownSequenceTrampoline( + handler: *Handler, + sequence: Handler.UnknownSequence, + ) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.unknown_sequence orelse return; + const value = UnknownSequence.cval(switch (sequence) { + .apc => |apc_value| .{ + .apc = .{ + .truncated = apc_value.truncated, + .content = .{ + .ptr = apc_value.content.ptr, + .len = apc_value.content.len, + }, + }, + }, + }); + func(@ptrCast(wrapper), wrapper.effects.userdata, &value); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.size_cb orelse return null; @@ -905,6 +961,8 @@ pub const Option = enum(c_int) { title_report = 32, mode_default = 33, mode = 34, + unknown_sequence = 35, + unknown_max_bytes = 36, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -922,6 +980,7 @@ pub const Option = enum(c_int) { .progress_report => ?Effects.ProgressReportFn, .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, + .unknown_sequence => ?Effects.UnknownSequenceFn, .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, @@ -937,6 +996,7 @@ pub const Option = enum(c_int) { .scrollback_max_bytes, .scrollback_max_lines, .continuation_max_bytes, + .unknown_max_bytes, => ?*const usize, .selection => ?*const selection_c.CSelection, .default_cursor_style => ?*const TerminalCursorStyle, @@ -988,6 +1048,13 @@ fn setTyped( .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, .clipboard_write => wrapper.effects.clipboard_write = value, + .unknown_sequence => { + wrapper.effects.unknown_sequence = value; + wrapper.stream.handler.unknown_sequence = if (value != null) + &Effects.unknownSequenceTrampoline + else + null; + }, .title_report => wrapper.stream.handler.title_report = if (value) |ptr| ptr.* else @@ -1106,6 +1173,8 @@ fn setTyped( wrapper, if (value) |ptr| ptr.* else default_continuation_max_bytes, ), + .unknown_max_bytes => wrapper.stream.handler.apc_handler.unknown_max_bytes = + if (value) |ptr| ptr.* else 0, .mode, .mode_default => { const config = (value orelse return .invalid_value).*; const mode = config.toMode() orelse return .invalid_value; @@ -3861,6 +3930,114 @@ test "set progress_report callback" { try testing.expectEqual(@as(usize, cases.len), S.count); } +test "set unknown_sequence callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_terminal: Terminal = null; + var last_userdata: ?*anyopaque = null; + var last_tag: UnknownSequence.Tag = .apc; + var last_truncated: bool = false; + var content: [64]u8 = undefined; + var content_len: usize = 0; + + fn unknownSequence( + terminal_: Terminal, + ud: ?*anyopaque, + sequence: *const UnknownSequence.C, + ) callconv(lib.calling_conv) void { + count += 1; + last_terminal = terminal_; + last_userdata = ud; + last_tag = sequence.tag; + const apc_value = sequence.value.apc; + last_truncated = apc_value.truncated; + content_len = @min(apc_value.content.len, content.len); + @memcpy(content[0..content_len], apc_value.content.ptr[0..content_len]); + } + }; + S.count = 0; + S.last_terminal = null; + S.last_userdata = null; + S.last_tag = .apc; + S.last_truncated = false; + S.content_len = 0; + + var sentinel: u8 = 101; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + + const max_bytes: usize = 8; + try testing.expectEqual(Result.success, set( + t, + .unknown_max_bytes, + @ptrCast(&max_bytes), + )); + try testing.expectEqual(max_bytes, t.?.stream.handler.apc_handler.unknown_max_bytes); + + // A byte limit without a callback performs no external effect. + const before_callback = "\x1B_abc;xy\x1B\\"; + vt_write(t, before_callback, before_callback.len); + try testing.expectEqual(@as(usize, 0), S.count); + try testing.expect(t.?.stream.handler.unknown_sequence == null); + + try testing.expectEqual(Result.success, set( + t, + .unknown_sequence, + @ptrCast(&S.unknownSequence), + )); + try testing.expect(t.?.stream.handler.unknown_sequence != null); + + // Split a complete APC across writes to exercise persistent parser state. + const seq_a = "\x1B_abc;"; + const seq_b = "xy\x1B\\"; + vt_write(t, seq_a, seq_a.len); + try testing.expectEqual(@as(usize, 0), S.count); + vt_write(t, seq_b, seq_b.len); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(t, S.last_terminal); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(UnknownSequence.Tag.apc, S.last_tag); + try testing.expect(!S.last_truncated); + try testing.expectEqualStrings("abc;xy", S.content[0..S.content_len]); + + // Content beyond the generic limit is omitted and marked truncated. + const truncated = "\x1B_abcdefghijkl\x1B\\"; + vt_write(t, truncated, truncated.len); + try testing.expectEqual(@as(usize, 2), S.count); + try testing.expect(S.last_truncated); + try testing.expectEqualStrings("abcdefgh", S.content[0..S.content_len]); + + // CAN aborts the APC and must not invoke the callback. + const aborted = "\x1B_abcdef\x18"; + vt_write(t, aborted, aborted.len); + try testing.expectEqual(@as(usize, 2), S.count); + + // Clearing the callback restores the null fast path immediately. + try testing.expectEqual(Result.success, set(t, .unknown_sequence, null)); + try testing.expect(t.?.stream.handler.unknown_sequence == null); + vt_write(t, before_callback, before_callback.len); + try testing.expectEqual(@as(usize, 2), S.count); + + // A NULL limit disables capture even after reinstalling the callback. + try testing.expectEqual(Result.success, set( + t, + .unknown_sequence, + @ptrCast(&S.unknownSequence), + )); + try testing.expectEqual(Result.success, set(t, .unknown_max_bytes, null)); + try testing.expectEqual(@as(usize, 0), t.?.stream.handler.apc_handler.unknown_max_bytes); + vt_write(t, before_callback, before_callback.len); + try testing.expectEqual(@as(usize, 2), S.count); +} + test "set pwd_changed callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 40a5b63e0..df84d3ede 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -75,6 +75,8 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, + .{ "GhosttyTerminalUnknownSequence", StructInfo.init(terminal.UnknownSequence.C) }, + .{ "GhosttyTerminalUnknownStringSequence", StructInfo.init(terminal.UnknownStringSequence) }, .{ "GhosttyWriter", StructInfo.init(io.Writer) }, }); }; @@ -223,6 +225,8 @@ test "json parses" { try std.testing.expect(root.contains("GhosttyClipboardWrite")); try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); try std.testing.expect(root.contains("GhosttyTerminalModeConfig")); + try std.testing.expect(root.contains("GhosttyTerminalUnknownSequence")); + try std.testing.expect(root.contains("GhosttyTerminalUnknownStringSequence")); try std.testing.expect(root.contains("GhosttyReader")); try std.testing.expect(root.contains("GhosttyWriter")); @@ -238,6 +242,16 @@ test "json parses" { try std.testing.expect(clipboard_write_fields.contains("contents")); try std.testing.expect(clipboard_write_fields.contains("contents_len")); + const unknown_sequence = root.get("GhosttyTerminalUnknownSequence").?.object; + const unknown_sequence_fields = unknown_sequence.get("fields").?.object; + try std.testing.expect(unknown_sequence_fields.contains("tag")); + try std.testing.expect(unknown_sequence_fields.contains("value")); + + const unknown_string = root.get("GhosttyTerminalUnknownStringSequence").?.object; + const unknown_string_fields = unknown_string.get("fields").?.object; + try std.testing.expect(unknown_string_fields.contains("truncated")); + try std.testing.expect(unknown_string_fields.contains("content")); + const reader_fields = root.get("GhosttyReader").?.object .get("fields").?.object; try std.testing.expect(reader_fields.contains("read")); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index cee4bcb94..fb846aeff 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -59,6 +59,7 @@ pub const Terminal = @import("Terminal.zig"); pub const TerminalStream = stream_terminal.Stream; pub const Stream = stream.Stream; pub const StreamAction = stream.Action; +pub const UnknownSequence = stream_terminal.Handler.UnknownSequence; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 65c7cc5e0..1d0341ed7 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -110,7 +110,7 @@ pub const Action = union(Key) { dcs_put: u8, dcs_unhook, apc_start, - apc_end, + apc_end: ApcEnd, apc_put: u8, apc_put_slice: ApcPutSlice, end_hyperlink, @@ -291,6 +291,11 @@ pub const Action = union(Key) { } }; + pub const ApcEnd = extern struct { + /// False when CAN, SUB, or another aborting transition ended the APC. + terminated: bool, + }; + pub const InvokeCharset = lib.Struct(lib.target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, @@ -766,8 +771,29 @@ pub fn Stream(comptime H: type) type { if (self.parser.state == .sos_pm_apc_string) { offset += self.consumeApcString(input[offset..]); if (offset >= input.len) return input.len; - // The next byte exits the string state; let - // nextNonUtf8 below handle it. + + // Fast-path normal string termination. This matches + // Parser.next's exit and entry actions while avoiding + // the generic action loop for every completed APC. + switch (input[offset]) { + std.ascii.control_code.esc => { + self.parser.clear(); + self.parser.state = .escape; + self.handler.vt(.apc_end, .{ .terminated = true }); + offset += 1; + continue; + }, + 0x9C => { + self.parser.state = .ground; + self.handler.vt(.apc_end, .{ .terminated = true }); + offset += 1; + continue; + }, + else => {}, + } + + // Aborting transitions need the scalar path so the + // handler can distinguish them from terminators. } } @@ -1109,7 +1135,9 @@ pub fn Stream(comptime H: type) type { .dcs_unhook => self.handler.vt(.dcs_unhook, {}), .apc_start => self.handler.vt(.apc_start, {}), .apc_put => |code| self.handler.vt(.apc_put, code), - .apc_end => self.handler.vt(.apc_end, {}), + .apc_end => self.handler.vt(.apc_end, .{ + .terminated = c == std.ascii.control_code.esc or c == 0x9C, + }), } } } @@ -3989,6 +4017,18 @@ test "stream: apc bulk slice" { } } +test "stream: apc bulk slice C1 ST" { + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); + s.nextSlice("\x1b_Gpayload\x9c"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gpayload", + s.handler.buf[0..s.handler.len], + ); +} + test "stream: apc bulk slice split across inputs" { var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); s.nextSlice("\x1b_Gf=24,s=10"); diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 1aa8f5579..c13f82769 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -70,6 +70,12 @@ pub const Handler = struct { /// The DCS command handler maintains state for DCS queries. dcs_handler: dcs.Handler = .{}, + /// Called for sequence identifiers not supported by this library. + /// Currently, only APC is reported. Content is borrowed and only valid + /// for the duration of the callback. Set `apc_handler.unknown_max_bytes` + /// before starting the Stream to enable APC capture. + unknown_sequence: ?*const fn (*Handler, UnknownSequence) void = null, + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -157,6 +163,18 @@ pub const Handler = struct { }; }; + /// A sequence unsupported by the active handler. Payload data is borrowed + /// only for the duration of the handler callback. + pub const UnknownSequence = union(enum) { + apc: String, + + /// Content between a string sequence's introducer and terminator. + pub const String = struct { + content: []const u8, + truncated: bool, + }; + }; + pub fn init(terminal: *Terminal) Handler { return .{ .terminal = terminal, @@ -211,6 +229,11 @@ pub const Handler = struct { }; } + fn unknownSequence(self: *Handler, value: UnknownSequence) void { + const func = self.unknown_sequence orelse return; + func(self, value); + } + inline fn vtFallible( self: *Handler, comptime action: Action.Tag, @@ -326,7 +349,7 @@ pub const Handler = struct { .apc_start => self.apc_handler.start(), .apc_put => self.apc_handler.feed(self.terminal.gpa(), value), .apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes), - .apc_end => self.apcEnd(), + .apc_end => self.apcEnd(value.terminated), // Effect-based handlers .bell => self.bell(), @@ -948,13 +971,18 @@ pub const Handler = struct { } } - fn apcEnd(self: *Handler) void { + fn apcEnd(self: *Handler, terminated: bool) void { const io = self.terminal.io(); const alloc = self.terminal.gpa(); - var cmd = self.apc_handler.end() orelse return; - defer cmd.deinit(alloc); - - switch (cmd) { + var result = self.apc_handler.end() orelse return; + defer result.deinit(alloc); + switch (result) { + .unknown => |*unknown| { + if (terminated) self.unknownSequence(.{ .apc = .{ + .content = unknown.content, + .truncated = unknown.truncated, + } }); + }, .kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) { if (self.terminal.kittyGraphics( io, @@ -1016,6 +1044,51 @@ test "resize clears synchronized output on unchanged cell dimensions" { try testing.expectEqual(@as(u32, 432), t.height_px); } +test "unknown APC effect callback" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var count: usize = 0; + var content: [16]u8 = undefined; + var content_len: usize = undefined; + var truncated: bool = undefined; + + fn unknownSequence(_: *Handler, value: Handler.UnknownSequence) void { + switch (value) { + .apc => |apc_value| { + content_len = apc_value.content.len; + @memcpy(content[0..apc_value.content.len], apc_value.content); + truncated = apc_value.truncated; + }, + } + count += 1; + } + }; + S.count = 0; + + var handler: Handler = .init(&t); + handler.unknown_sequence = &S.unknownSequence; + handler.apc_handler.unknown_max_bytes = 8; + var s: Stream = .init(.{ + .allocator = testing.allocator, + .handler = handler, + }); + defer s.deinit(); + + // Unknown OSC commands retain their legacy behavior and are ignored. + s.nextSlice("\x1B]999;abcdef\x07"); + s.nextSlice("\x1B_abcd;payload\x1B\\"); + + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqualStrings("abcd;pay", S.content[0..S.content_len]); + try testing.expect(S.truncated); + + // Aborted unknown APCs are suppressed. + s.nextSlice("\x1B_Xpayload\x18"); + try testing.expectEqual(@as(usize, 1), S.count); +} + test "resize reports mode 2048 geometry" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ddc5eb82b..ac269e7bc 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -476,11 +476,12 @@ pub const StreamHandler = struct { } pub fn apcEnd(self: *StreamHandler) !void { - var cmd = self.apc.end() orelse return; - defer cmd.deinit(self.alloc); + var result = self.apc.end() orelse return; + defer result.deinit(self.alloc); - // log.warn("APC command: {}", .{cmd}); - switch (cmd) { + // log.warn("APC command: {}", .{result}); + switch (result) { + .unknown => return, .kitty => |*kitty_cmd| { if (self.terminal.kittyGraphics(global.io(), self.alloc, kitty_cmd)) |resp| { var buf: [1024]u8 = undefined;