From c3bf7246f64a60194e957805f25aa82c76a426e7 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Tue, 24 Dec 2024 21:50:57 +0100 Subject: [PATCH] terminal: parse ConEmu progress OSC 9 Fixes #3119 ConEmu and iTerm2 both use OSC 9 to implement different things. iTerm2 uses it to implement desktop notifications, while ConEmu uses it to implement various OS commands. Ghostty has supported iTerm2 OSC 9 for a while, but it didn't (and doesn't) support ConEmu OSC 9. This means that if a program tries to send a ConEmu OSC 9 to Ghostty, it will turn into a desktop notification. This commit adds parsing for ConEmu OSC 9 progress reports. This means that these specific syntaxes can never be desktop notifications, but they're quite strange to be desktop notifications anyway so this should be an okay tradeoff. This doesn't actually _do anything with the progress reports_, it just parses them so that they don't turn into desktop notifications. --- src/terminal/osc.zig | 252 ++++++++++++++++++++++++++++++++++++++-- src/terminal/stream.zig | 4 + 2 files changed, 249 insertions(+), 7 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 34bc46745..3f7236a2c 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -158,6 +158,12 @@ pub const Command = union(enum) { /// End a hyperlink (OSC 8) hyperlink_end: void, + /// Set progress state (OSC 9;4) + progress: struct { + state: ProgressState, + progress: ?u8 = null, + }, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -173,6 +179,14 @@ pub const Command = union(enum) { }; } }; + + pub const ProgressState = enum { + remove, + set, + @"error", + indeterminate, + pause, + }; }; /// The terminator used to end an OSC command. For OSC commands that demand @@ -322,6 +336,27 @@ pub const Parser = struct { // https://sw.kovidgoyal.net/kitty/color-stack/#id1 kitty_color_protocol_key, kitty_color_protocol_value, + + // OSC 9 is used by ConEmu and iTerm2 for different things. + // iTerm2 uses it to post a notification[1]. + // ConEmu uses it to implement many custom functions[2]. + // + // Some Linux applications (namely systemd and flatpak) have + // adopted the ConEmu implementation but this causes bogus + // notifications on iTerm2 compatible terminal emulators. + // + // Ghostty supports both by disallowing ConEmu-specific commands + // from being shown as desktop notifications. + // + // [1]: https://iterm2.com/documentation-escape-codes.html + // [2]: https://conemu.github.io/en/AnsiEscapeCodes.html#OSC_Operating_system_commands + osc_9, + + // ConEmu specific substates + conemu_progress_prestate, + conemu_progress_state, + conemu_progress_prevalue, + conemu_progress_value, }; /// This must be called to clean up any allocated memory. @@ -735,18 +770,99 @@ pub const Parser = struct { .@"9" => switch (c) { ';' => { - self.command = .{ .show_desktop_notification = .{ - .title = "", - .body = undefined, - } }; - - self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; self.buf_start = self.buf_idx; - self.state = .string; + self.state = .osc_9; }, else => self.state = .invalid, }, + .osc_9 => switch (c) { + '4' => { + self.state = .conemu_progress_prestate; + }, + + // Todo: parse out other ConEmu operating system commands. + // Even if we don't support them we probably don't want + // them showing up as desktop notifications. + + else => self.showDesktopNotification(), + }, + + .conemu_progress_prestate => switch (c) { + ';' => { + self.command = .{ .progress = .{ + .state = undefined, + } }; + self.state = .conemu_progress_state; + }, + else => self.showDesktopNotification(), + }, + + .conemu_progress_state => switch (c) { + '0' => { + self.command.progress.state = .remove; + self.state = .conemu_progress_prevalue; + self.complete = true; + }, + '1' => { + self.command.progress.state = .set; + self.command.progress.progress = 0; + self.state = .conemu_progress_prevalue; + }, + '2' => { + self.command.progress.state = .@"error"; + self.complete = true; + self.state = .conemu_progress_prevalue; + }, + '3' => { + self.command.progress.state = .indeterminate; + self.complete = true; + self.state = .conemu_progress_prevalue; + }, + '4' => { + self.command.progress.state = .pause; + self.complete = true; + self.state = .conemu_progress_prevalue; + }, + else => self.showDesktopNotification(), + }, + + .conemu_progress_prevalue => switch (c) { + ';' => { + self.state = .conemu_progress_value; + }, + + else => self.showDesktopNotification(), + }, + + .conemu_progress_value => switch (c) { + '0'...'9' => value: { + // No matter what substate we're in, a number indicates + // a completed ConEmu progress command. + self.complete = true; + + // If we aren't a set substate, then we don't care + // about the value. + const p = &self.command.progress; + if (p.state != .set) break :value; + assert(p.progress != null); + + // If we're over 100% we're done. + if (p.progress.? >= 100) break :value; + + // If we're over 10 then any new digit forces us to + // be 100. + if (p.progress.? >= 10) + p.progress = 100 + else { + const d = std.fmt.charToDigit(c, 10) catch 0; + p.progress = @min(100, (p.progress.? * 10) + d); + } + }, + + else => self.showDesktopNotification(), + }, + .query_fg_color => switch (c) { '?' => { self.command = .{ .report_color = .{ .kind = .foreground } }; @@ -901,6 +1017,16 @@ pub const Parser = struct { } } + fn showDesktopNotification(self: *Parser) void { + self.command = .{ .show_desktop_notification = .{ + .title = "", + .body = undefined, + } }; + + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.state = .string; + } + fn prepAllocableString(self: *Parser) void { assert(self.buf_dynamic == null); @@ -1532,6 +1658,118 @@ test "OSC: show desktop notification with title" { try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); } +test "OSC: OSC9 progress set" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .set); + try testing.expect(cmd.progress.progress == 100); +} + +test "OSC: OSC9 progress set overflow" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;1;900"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .set); + try testing.expect(cmd.progress.progress == 100); +} + +test "OSC: OSC9 progress set single digit" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;1;9"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .set); + try testing.expect(cmd.progress.progress == 9); +} + +test "OSC: OSC9 progress set double digit" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;1;94"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .set); + try testing.expect(cmd.progress.progress == 94); +} + +test "OSC: OSC9 progress set extra semicolon triggers desktop notification" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;1;100;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;1;100;"); +} + +test "OSC: OSC9 progress remove with no progress" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;0;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .remove); + try testing.expect(cmd.progress.progress == null); +} + +test "OSC: OSC9 progress remove ignores progress" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;0;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .remove); + try testing.expect(cmd.progress.progress == null); +} + +test "OSC: OSC9 progress remove extra semicolon" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;0;100;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;0;100;"); +} + test "OSC: empty param" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index b8d60a13f..8e8be90b1 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1447,6 +1447,10 @@ pub fn Stream(comptime Handler: type) type { return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + + .progress => { + log.warn("unimplemented OSC callback: {}", .{cmd}); + }, } // Fall through for when we don't have a handler.