mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 04:39:01 +00:00
Move DECRQSS response encoding into the terminal DCS handler so both the full termio path and libghostty-vt terminal stream emit the same replies. The C API stream now maintains and releases DCS parser state and forwards responses through write_pty.
571 lines
18 KiB
Zig
571 lines
18 KiB
Zig
const std = @import("std");
|
|
const build_options = @import("terminal_options");
|
|
const assert = @import("../quirks.zig").inlineAssert;
|
|
const Allocator = std.mem.Allocator;
|
|
const terminal = @import("main.zig");
|
|
const DCS = terminal.DCS;
|
|
|
|
const log = std.log.scoped(.terminal_dcs);
|
|
|
|
/// DCS command handler. This should be hooked into a terminal.Stream handler.
|
|
/// The hook/put/unhook functions are meant to be called from the
|
|
/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively.
|
|
pub const Handler = struct {
|
|
state: State = .{ .inactive = {} },
|
|
|
|
/// Maximum bytes any DCS command can take. This is to prevent
|
|
/// malicious input from causing us to allocate too much memory.
|
|
/// This is arbitrarily set to 1MB today, increase if needed.
|
|
max_bytes: usize = 1024 * 1024,
|
|
|
|
pub fn deinit(self: *Handler) void {
|
|
self.discard();
|
|
}
|
|
|
|
pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) ?Command {
|
|
assert(self.state == .inactive);
|
|
|
|
// Initialize our state to ignore in case of error
|
|
self.state = .ignore;
|
|
|
|
// Try to parse the hook.
|
|
const hk_ = self.tryHook(alloc, dcs) catch |err| {
|
|
log.info("error initializing DCS hook, will ignore hook err={}", .{err});
|
|
return null;
|
|
};
|
|
const hk = hk_ orelse {
|
|
log.info("unknown DCS hook: {}", .{dcs});
|
|
return null;
|
|
};
|
|
|
|
self.state = hk.state;
|
|
return hk.command;
|
|
}
|
|
|
|
const Hook = struct {
|
|
state: State,
|
|
command: ?Command = null,
|
|
};
|
|
|
|
fn tryHook(self: Handler, alloc: Allocator, dcs: DCS) !?Hook {
|
|
return switch (dcs.intermediates.len) {
|
|
0 => switch (dcs.final) {
|
|
// Tmux control mode
|
|
'p' => tmux: {
|
|
if (comptime !build_options.tmux_control_mode) {
|
|
log.debug("tmux control mode not enabled in build, ignoring", .{});
|
|
break :tmux null;
|
|
}
|
|
|
|
// Tmux control mode must start with ESC P 1000 p
|
|
if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null;
|
|
|
|
break :tmux .{
|
|
.state = .{
|
|
.tmux = .{
|
|
.max_bytes = self.max_bytes,
|
|
.buffer = try .initCapacity(
|
|
alloc,
|
|
128, // Arbitrary choice to limit initial reallocs
|
|
),
|
|
},
|
|
},
|
|
.command = .{ .tmux = .enter },
|
|
};
|
|
},
|
|
|
|
else => null,
|
|
},
|
|
|
|
1 => switch (dcs.intermediates[0]) {
|
|
'+' => switch (dcs.final) {
|
|
// XTGETTCAP
|
|
// https://github.com/mitchellh/ghostty/issues/517
|
|
'q' => .{
|
|
.state = .{
|
|
.xtgettcap = try .initCapacity(
|
|
alloc,
|
|
128, // Arbitrary choice
|
|
),
|
|
},
|
|
},
|
|
|
|
else => null,
|
|
},
|
|
|
|
'$' => switch (dcs.final) {
|
|
// DECRQSS
|
|
'q' => .{ .state = .{
|
|
.decrqss = .{},
|
|
} },
|
|
|
|
else => null,
|
|
},
|
|
|
|
else => null,
|
|
},
|
|
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
/// Put a byte into the DCS handler. This will return a command
|
|
/// if a command needs to be executed.
|
|
pub fn put(self: *Handler, byte: u8) ?Command {
|
|
return self.tryPut(byte) catch |err| {
|
|
// On error we just discard our state and ignore the rest
|
|
log.info("error putting byte into DCS handler err={}", .{err});
|
|
self.discard();
|
|
self.state = .ignore;
|
|
return null;
|
|
};
|
|
}
|
|
|
|
fn tryPut(self: *Handler, byte: u8) !?Command {
|
|
switch (self.state) {
|
|
.inactive,
|
|
.ignore,
|
|
=> {},
|
|
|
|
.tmux => |*tmux| if (comptime build_options.tmux_control_mode) {
|
|
return .{
|
|
.tmux = (try tmux.put(byte)) orelse return null,
|
|
};
|
|
} else unreachable,
|
|
|
|
.xtgettcap => |*list| {
|
|
if (list.written().len >= self.max_bytes) {
|
|
return error.OutOfMemory;
|
|
}
|
|
|
|
try list.writer.writeByte(byte);
|
|
},
|
|
|
|
.decrqss => |*buffer| {
|
|
if (buffer.len >= buffer.data.len) {
|
|
return error.OutOfMemory;
|
|
}
|
|
|
|
buffer.data[buffer.len] = byte;
|
|
buffer.len += 1;
|
|
},
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
pub fn unhook(self: *Handler) ?Command {
|
|
// Note: we do NOT call deinit here on purpose because some commands
|
|
// transfer memory ownership. If state needs cleanup, the switch
|
|
// prong below should handle it.
|
|
defer self.state = .inactive;
|
|
|
|
return switch (self.state) {
|
|
.inactive,
|
|
.ignore,
|
|
=> null,
|
|
|
|
.tmux => if (comptime build_options.tmux_control_mode) tmux: {
|
|
self.state.deinit();
|
|
break :tmux .{ .tmux = .exit };
|
|
} else unreachable,
|
|
|
|
.xtgettcap => |*list| xtgettcap: {
|
|
// Note: purposely do not deinit our state here because
|
|
// we copy it into the resulting command.
|
|
const items = list.written();
|
|
for (items, 0..) |b, i| items[i] = std.ascii.toUpper(b);
|
|
break :xtgettcap .{ .xtgettcap = .{ .data = list.* } };
|
|
},
|
|
|
|
.decrqss => |buffer| .{ .decrqss = switch (buffer.len) {
|
|
0 => .none,
|
|
1 => switch (buffer.data[0]) {
|
|
'm' => .sgr,
|
|
'r' => .decstbm,
|
|
's' => .decslrm,
|
|
else => .none,
|
|
},
|
|
2 => switch (buffer.data[0]) {
|
|
' ' => switch (buffer.data[1]) {
|
|
'q' => .decscusr,
|
|
else => .none,
|
|
},
|
|
else => .none,
|
|
},
|
|
else => unreachable,
|
|
} },
|
|
};
|
|
}
|
|
|
|
fn discard(self: *Handler) void {
|
|
self.state.deinit();
|
|
self.state = .inactive;
|
|
}
|
|
};
|
|
|
|
pub const Command = union(enum) {
|
|
/// XTGETTCAP
|
|
xtgettcap: XTGETTCAP,
|
|
|
|
/// DECRQSS
|
|
decrqss: DECRQSS,
|
|
|
|
/// Tmux control mode
|
|
tmux: if (build_options.tmux_control_mode)
|
|
terminal.tmux.ControlNotification
|
|
else
|
|
void,
|
|
|
|
pub fn deinit(self: *Command) void {
|
|
switch (self.*) {
|
|
.xtgettcap => |*v| v.data.deinit(),
|
|
.decrqss => {},
|
|
.tmux => {},
|
|
}
|
|
}
|
|
|
|
pub const XTGETTCAP = struct {
|
|
data: std.Io.Writer.Allocating,
|
|
i: usize = 0,
|
|
|
|
/// Returns the next terminfo key being requested and null
|
|
/// when there are no more keys. The returned value is NOT hex-decoded
|
|
/// because we expect to use a comptime lookup table.
|
|
pub fn next(self: *XTGETTCAP) ?[]const u8 {
|
|
const items = self.data.written();
|
|
if (self.i >= items.len) return null;
|
|
var rem = items[self.i..];
|
|
const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len;
|
|
|
|
// Note that if we're at the end, idx + 1 is len + 1 so we're over
|
|
// the end but that's okay because our check above is >= so we'll
|
|
// never read.
|
|
self.i += idx + 1;
|
|
|
|
return rem[0..idx];
|
|
}
|
|
};
|
|
|
|
/// Supported DECRQSS settings
|
|
pub const DECRQSS = enum {
|
|
none,
|
|
sgr,
|
|
decscusr,
|
|
decstbm,
|
|
decslrm,
|
|
|
|
/// Fixed upper bound for an encoded DECRPSS response. The comptime
|
|
/// calculated max at the time of writing this was around 63 so this
|
|
/// leaves a ton of space for future stuff. We don't do the comptime
|
|
/// calculation cause it complicated the implementation a bit too
|
|
/// much.
|
|
pub const max_response_bytes = 256;
|
|
|
|
/// Encode the response for this request.
|
|
pub fn encode(
|
|
self: DECRQSS,
|
|
t: *terminal.Terminal,
|
|
response: []u8,
|
|
) ![]const u8 {
|
|
var writer: std.Io.Writer = .fixed(response);
|
|
|
|
const prefix_fmt = "\x1bP{d}$r";
|
|
const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
|
|
writer.end = prefix_len;
|
|
|
|
switch (self) {
|
|
.none => {},
|
|
.sgr => {
|
|
const buf = try t.printAttributes(writer.buffer[writer.end..]);
|
|
writer.end += buf.len;
|
|
try writer.writeByte('m');
|
|
},
|
|
.decscusr => {
|
|
const blink = t.modes.get(.cursor_blinking);
|
|
const style: u8 = switch (t.screens.active.cursor.cursor_style) {
|
|
.block, .block_hollow => if (blink) 1 else 2,
|
|
.underline => if (blink) 3 else 4,
|
|
.bar => if (blink) 5 else 6,
|
|
};
|
|
try writer.print("{d} q", .{style});
|
|
},
|
|
.decstbm => try writer.print("{d};{d}r", .{
|
|
t.scrolling_region.top + 1,
|
|
t.scrolling_region.bottom + 1,
|
|
}),
|
|
.decslrm => if (t.modes.get(.enable_left_and_right_margin)) {
|
|
try writer.print("{d};{d}s", .{
|
|
t.scrolling_region.left + 1,
|
|
t.scrolling_region.right + 1,
|
|
});
|
|
},
|
|
}
|
|
|
|
const valid = writer.end > prefix_len;
|
|
try writer.writeAll("\x1b\\");
|
|
_ = try std.fmt.bufPrint(
|
|
response[0..prefix_len],
|
|
prefix_fmt,
|
|
.{@intFromBool(valid)},
|
|
);
|
|
return writer.buffered();
|
|
}
|
|
};
|
|
};
|
|
|
|
const State = union(enum) {
|
|
/// We're not in a DCS state at the moment.
|
|
inactive,
|
|
|
|
/// We're hooked, but its an unknown DCS command or one that went
|
|
/// invalid due to some bad input, so we're ignoring the rest.
|
|
ignore,
|
|
|
|
/// XTGETTCAP
|
|
xtgettcap: std.Io.Writer.Allocating,
|
|
|
|
/// DECRQSS
|
|
decrqss: struct {
|
|
data: [2]u8 = undefined,
|
|
len: u2 = 0,
|
|
},
|
|
|
|
/// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
|
|
tmux: if (build_options.tmux_control_mode)
|
|
terminal.tmux.ControlParser
|
|
else
|
|
void,
|
|
|
|
pub fn deinit(self: *State) void {
|
|
switch (self.*) {
|
|
.inactive,
|
|
.ignore,
|
|
=> {},
|
|
|
|
.xtgettcap => |*v| v.deinit(),
|
|
.decrqss => {},
|
|
.tmux => |*v| if (comptime build_options.tmux_control_mode) {
|
|
v.deinit();
|
|
} else unreachable,
|
|
}
|
|
}
|
|
};
|
|
|
|
test "unknown DCS command" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .final = 'A' }) == null);
|
|
try testing.expect(h.state == .ignore);
|
|
try testing.expect(h.unhook() == null);
|
|
try testing.expect(h.state == .inactive);
|
|
}
|
|
|
|
test "XTGETTCAP command" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
|
for ("536D756C78") |byte| _ = h.put(byte);
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .xtgettcap);
|
|
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
|
|
try testing.expect(cmd.xtgettcap.next() == null);
|
|
}
|
|
|
|
test "XTGETTCAP mixed case" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
|
for ("536d756C78") |byte| _ = h.put(byte);
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .xtgettcap);
|
|
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
|
|
try testing.expect(cmd.xtgettcap.next() == null);
|
|
}
|
|
|
|
test "XTGETTCAP command multiple keys" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
|
for ("536D756C78;536D756C78") |byte| _ = h.put(byte);
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .xtgettcap);
|
|
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
|
|
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
|
|
try testing.expect(cmd.xtgettcap.next() == null);
|
|
}
|
|
|
|
test "XTGETTCAP command invalid data" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
|
for ("who;536D756C78") |byte| _ = h.put(byte);
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .xtgettcap);
|
|
try testing.expectEqualStrings("WHO", cmd.xtgettcap.next().?);
|
|
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
|
|
try testing.expect(cmd.xtgettcap.next() == null);
|
|
}
|
|
|
|
test "DECRQSS command" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
|
|
_ = h.put('m');
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .decrqss);
|
|
try testing.expect(cmd.decrqss == .sgr);
|
|
}
|
|
|
|
test "DECRQSS invalid command" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
|
|
_ = h.put('z');
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .decrqss);
|
|
try testing.expect(cmd.decrqss == .none);
|
|
|
|
h.discard();
|
|
|
|
try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
|
|
_ = h.put('"');
|
|
_ = h.put(' ');
|
|
_ = h.put('q');
|
|
try testing.expect(h.unhook() == null);
|
|
}
|
|
|
|
test "DECRQSS response encoding" {
|
|
const testing = std.testing;
|
|
|
|
var t: terminal.Terminal = try .init(
|
|
testing.io,
|
|
testing.allocator,
|
|
.{ .cols = 80, .rows = 24 },
|
|
);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
const S = struct {
|
|
fn expectResponse(
|
|
term: *terminal.Terminal,
|
|
request: Command.DECRQSS,
|
|
expected: []const u8,
|
|
) !void {
|
|
var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
|
|
const encoded = try request.encode(term, &buf);
|
|
try testing.expectEqualStrings(expected, encoded);
|
|
}
|
|
};
|
|
|
|
try S.expectResponse(&t, .none, "\x1BP0$r\x1B\\");
|
|
try S.expectResponse(&t, .sgr, "\x1BP1$r0m\x1B\\");
|
|
|
|
try t.setAttribute(.bold);
|
|
try t.setAttribute(.{ .underline = .curly });
|
|
try S.expectResponse(&t, .sgr, "\x1BP1$r0;1;4:3m\x1B\\");
|
|
|
|
t.setCursorStyle(.steady_underline);
|
|
try S.expectResponse(&t, .decscusr, "\x1BP1$r4 q\x1B\\");
|
|
|
|
t.scrolling_region.top = 4;
|
|
t.scrolling_region.bottom = 19;
|
|
try S.expectResponse(&t, .decstbm, "\x1BP1$r5;20r\x1B\\");
|
|
|
|
try S.expectResponse(&t, .decslrm, "\x1BP0$r\x1B\\");
|
|
t.modes.set(.enable_left_and_right_margin, true);
|
|
t.scrolling_region.left = 2;
|
|
t.scrolling_region.right = 69;
|
|
try S.expectResponse(&t, .decslrm, "\x1BP1$r3;70s\x1B\\");
|
|
}
|
|
|
|
test "DECRQSS largest response fits fixed buffer" {
|
|
const testing = std.testing;
|
|
|
|
var t: terminal.Terminal = try .init(
|
|
testing.io,
|
|
testing.allocator,
|
|
.{ .cols = 80, .rows = 24 },
|
|
);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
try t.setAttribute(.bold);
|
|
try t.setAttribute(.faint);
|
|
try t.setAttribute(.italic);
|
|
try t.setAttribute(.{ .underline = .dashed });
|
|
try t.setAttribute(.blink);
|
|
try t.setAttribute(.inverse);
|
|
try t.setAttribute(.invisible);
|
|
try t.setAttribute(.strikethrough);
|
|
try t.setAttribute(.{ .direct_color_fg = .{
|
|
.r = 255,
|
|
.g = 255,
|
|
.b = 255,
|
|
} });
|
|
try t.setAttribute(.{ .direct_color_bg = .{
|
|
.r = 255,
|
|
.g = 255,
|
|
.b = 255,
|
|
} });
|
|
|
|
var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
|
|
const encoded = try Command.DECRQSS.sgr.encode(&t, &buf);
|
|
|
|
const expected =
|
|
"\x1BP1$r0;1;2;3;4:5;5;7;8;9" ++
|
|
";38:2::255:255:255" ++
|
|
";48:2::255:255:255m\x1B\\";
|
|
try testing.expectEqualStrings(expected, encoded);
|
|
try testing.expect(encoded.len <= Command.DECRQSS.max_response_bytes);
|
|
}
|
|
|
|
test "tmux enter and implicit exit" {
|
|
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
|
|
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var h: Handler = .{};
|
|
defer h.deinit();
|
|
|
|
{
|
|
var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .tmux);
|
|
try testing.expect(cmd.tmux == .enter);
|
|
}
|
|
|
|
{
|
|
var cmd = h.unhook().?;
|
|
defer cmd.deinit();
|
|
try testing.expect(cmd == .tmux);
|
|
try testing.expect(cmd.tmux == .exit);
|
|
}
|
|
}
|