diff --git a/src/terminal/kitty.zig b/src/terminal/kitty.zig index bbef8d7bb4..50348e7db4 100644 --- a/src/terminal/kitty.zig +++ b/src/terminal/kitty.zig @@ -6,6 +6,7 @@ const key = @import("kitty/key.zig"); pub const clipboard = @import("kitty/clipboard.zig"); pub const color = @import("kitty/color.zig"); pub const dnd = @import("kitty/dnd.zig"); +pub const notification = @import("kitty/notification.zig"); pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {}; pub const KeyFlags = key.Flags; diff --git a/src/terminal/kitty/notification.zig b/src/terminal/kitty/notification.zig new file mode 100644 index 0000000000..e3dcca2d96 --- /dev/null +++ b/src/terminal/kitty/notification.zig @@ -0,0 +1,275 @@ +//! Kitty's desktop notification protocol (OSC 99), the part of it that +//! maps onto a plain desktop notification: a title and a body, sent in +//! one escape code or in chunks, plain or base64 encoded, and the query +//! for what the terminal supports. +//! +//! A finished notification is handed to the embedder through the same +//! effect as OSC 9 and OSC 777 notifications, so an embedder that shows +//! those shows these. Actions, close events, icons, buttons, sounds, +//! urgency and occasions are not implemented, and the query answer says +//! so, as the protocol asks. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/desktop-notifications/ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const osc = @import("../osc.zig"); + +const OSC = osc.Command.KittyDesktopNotification; + +/// The most bytes a title or body is kept to. The protocol leaves the +/// limit to the terminal; past it the text is cut, on a character. +pub const max_text_bytes = 16 * 1024; + +/// What the query answers: the payload types implemented, and the one +/// occasion a terminal that implements none must name. +const capabilities = "o=always:p=title,body,?"; + +/// A notification ready to show. Its text lives until the next call to +/// `State.handle` or `State.deinit`. +pub const Notification = struct { + title: []const u8, + body: []const u8, +}; + +/// The notification being assembled from chunks. +pub const State = struct { + /// The id of the notification in progress; empty when it has none. + id: std.ArrayList(u8) = .empty, + title: Text = .{}, + body: Text = .{}, + /// A notification is in progress: chunks of it came with `d=0`. + open: bool = false, + + pub fn deinit(self: *State, gpa: Allocator) void { + self.id.deinit(gpa); + self.title.deinit(gpa); + self.body.deinit(gpa); + } + + fn clear(self: *State) void { + self.id.clearRetainingCapacity(); + self.title.clear(); + self.body.clear(); + self.open = false; + } + + /// Handle one OSC 99. Any answer the program is owed is written to + /// `writer`. Returns the notification this completes, if any. + pub fn handle( + self: *State, + gpa: Allocator, + writer: *std.Io.Writer, + cmd: OSC, + ) (Allocator.Error || std.Io.Writer.Error)!?Notification { + const id = cmd.readOption(.i) orelse ""; + switch (cmd.readOption(.p)) { + .query => { + try writer.print("\x1b]99;i={s}:p=?;{s}{s}", .{ + if (id.len > 0) id else "0", + capabilities, + cmd.terminator.string(), + }); + return null; + }, + // These name a notification already shown, which is not + // tracked: nothing to close, nothing alive to report. + .close, .alive => return null, + .title, .body, .icon, .buttons, .unknown => {}, + } + + // A chunk of another notification ends the one in progress. + if (self.open and !std.mem.eql(u8, self.id.items, id)) self.clear(); + if (!self.open) { + self.clear(); + try self.id.appendSlice(gpa, id); + } + + const base64 = cmd.readOption(.e); + switch (cmd.readOption(.p)) { + .title => try self.title.append(gpa, cmd.payload, base64), + .body => try self.body.append(gpa, cmd.payload, base64), + else => {}, + } + + if (!cmd.readOption(.d)) { + self.open = true; + return null; + } + + self.open = false; + const title = try self.title.finish(gpa); + const body = try self.body.finish(gpa); + if (title.len == 0 and body.len == 0) return null; + return .{ .title = title, .body = body }; + } +}; + +/// A title or body as it arrives: plain text appended as it is, base64 +/// decoded a whole group of four characters at a time, so a payload +/// chunked before encoding (each chunk padded) and one chunked after +/// (groups split across chunks) decode alike. +const Text = struct { + bytes: std.ArrayList(u8) = .empty, + /// Base64 characters not yet making a whole group. + group: [4]u8 = undefined, + group_len: u3 = 0, + + fn deinit(self: *Text, gpa: Allocator) void { + self.bytes.deinit(gpa); + } + + fn clear(self: *Text) void { + self.bytes.clearRetainingCapacity(); + self.group_len = 0; + } + + fn append( + self: *Text, + gpa: Allocator, + payload: []const u8, + base64: bool, + ) Allocator.Error!void { + if (!base64) return self.keep(gpa, payload); + for (payload) |c| { + self.group[self.group_len] = c; + self.group_len += 1; + if (self.group_len == 4) { + self.group_len = 0; + try self.decode(gpa, self.group[0..4]); + } + } + } + + /// The text, its last base64 characters decoded as if padded, cut + /// to `max_text_bytes` on a character and to valid UTF-8. + fn finish(self: *Text, gpa: Allocator) Allocator.Error![]const u8 { + if (self.group_len > 1) { + var padded: [4]u8 = .{ '=', '=', '=', '=' }; + @memcpy(padded[0..self.group_len], self.group[0..self.group_len]); + try self.decode(gpa, &padded); + } + self.group_len = 0; + const text = self.bytes.items; + return text[0..validUtf8Prefix(text)]; + } + + fn decode(self: *Text, gpa: Allocator, group: *const [4]u8) Allocator.Error!void { + const decoder = std.base64.standard.Decoder; + var out: [3]u8 = undefined; + const len = decoder.calcSizeForSlice(group) catch return; + decoder.decode(out[0..len], group) catch return; + try self.keep(gpa, out[0..len]); + } + + fn keep(self: *Text, gpa: Allocator, bytes: []const u8) Allocator.Error!void { + const room = max_text_bytes -| self.bytes.items.len; + try self.bytes.appendSlice(gpa, bytes[0..@min(room, bytes.len)]); + } +}; + +/// The length of the longest prefix of `bytes` that is valid UTF-8. +fn validUtf8Prefix(bytes: []const u8) usize { + var i: usize = 0; + while (i < bytes.len) { + const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch return i; + if (i + len > bytes.len) return i; + _ = std.unicode.utf8Decode(bytes[i..][0..len]) catch return i; + i += len; + } + return i; +} + +const testing = std.testing; + +fn command(metadata: []const u8, payload: []const u8) OSC { + return .{ .metadata = metadata, .payload = payload, .terminator = .st }; +} + +const Harness = struct { + state: State = .{}, + out: std.Io.Writer.Allocating, + + fn init() Harness { + return .{ .out = .init(testing.allocator) }; + } + + fn deinit(self: *Harness) void { + self.state.deinit(testing.allocator); + self.out.deinit(); + } + + fn send(self: *Harness, metadata: []const u8, payload: []const u8) !?Notification { + return self.state.handle(testing.allocator, &self.out.writer, command(metadata, payload)); + } +}; + +test "a one-line notification is its title" { + var h: Harness = .init(); + defer h.deinit(); + const n = (try h.send("", "Hello world")).?; + try testing.expectEqualStrings("Hello world", n.title); + try testing.expectEqualStrings("", n.body); +} + +test "a title and a body in chunks under one id" { + var h: Harness = .init(); + defer h.deinit(); + try testing.expect(try h.send("i=1:d=0", "Hello ") == null); + try testing.expect(try h.send("i=1:d=0", "world") == null); + const n = (try h.send("i=1:p=body", "This is cool")).?; + try testing.expectEqualStrings("Hello world", n.title); + try testing.expectEqualStrings("This is cool", n.body); +} + +test "base64 chunked before or after encoding decodes alike" { + var h: Harness = .init(); + defer h.deinit(); + // "Hello" and " world", each encoded with its padding. + try testing.expect(try h.send("i=a:e=1:d=0", "SGVsbG8=") == null); + const before = (try h.send("i=a:e=1", "IHdvcmxk")).?; + try testing.expectEqualStrings("Hello world", before.title); + // "Hello world" encoded, then split mid-group, its padding dropped. + try testing.expect(try h.send("i=b:e=1:d=0", "SGVsbG8gd2") == null); + const after = (try h.send("i=b:e=1", "9ybGQ")).?; + try testing.expectEqualStrings("Hello world", after.title); +} + +test "a chunk of another notification drops the one in progress" { + var h: Harness = .init(); + defer h.deinit(); + try testing.expect(try h.send("i=1:d=0", "lost") == null); + const n = (try h.send("i=2", "kept")).?; + try testing.expectEqualStrings("kept", n.title); +} + +test "the query names what is implemented" { + var h: Harness = .init(); + defer h.deinit(); + try testing.expect(try h.send("i=q:p=?", "") == null); + try testing.expectEqualStrings( + "\x1b]99;i=q:p=?;o=always:p=title,body,?\x1b\\", + h.out.written(), + ); +} + +test "close, alive and empty notifications show nothing" { + var h: Harness = .init(); + defer h.deinit(); + try testing.expect(try h.send("i=1:p=close", "") == null); + try testing.expect(try h.send("i=1:p=alive", "") == null); + try testing.expect(try h.send("i=1:p=icon", "") == null); + try testing.expectEqualStrings("", h.out.written()); +} + +test "text is kept to its bound, on a character" { + var h: Harness = .init(); + defer h.deinit(); + const chunk = "é" ** 1000; + var sent: usize = 0; + while (sent <= max_text_bytes) : (sent += chunk.len) { + try testing.expect(try h.send("i=big:d=0", chunk) == null); + } + const n = (try h.send("i=big", "")).?; + try testing.expectEqual(max_text_bytes, n.title.len); + try testing.expect(std.unicode.utf8ValidateSlice(n.title)); +} diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index b8ac0c900e..fbb0e4fb6f 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -25,6 +25,10 @@ pub const OSC = struct { /// The terminator that was used in case we need to send a response. terminator: Terminator, + pub const C = void; + + pub fn cval(_: OSC) C {} + /// Decode an option from the metadata. pub fn readOption(self: OSC, comptime key: Option) key.Type() { return key.read(self.metadata); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 8d69287233..6899e01236 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -130,6 +130,7 @@ pub const Action = union(Key) { semantic_prompt: SemanticPrompt, kitty_clipboard: KittyClipboard, kitty_dnd: KittyDnd, + kitty_desktop_notification: KittyDesktopNotification, resize_window: ResizeWindow, osc_unknown: osc.Command.Unknown, mouse_shape_reset, @@ -234,6 +235,7 @@ pub const Action = union(Key) { "semantic_prompt", "kitty_clipboard", "kitty_dnd", + "kitty_desktop_notification", "resize_window", "osc_unknown", "mouse_shape_reset", @@ -468,6 +470,7 @@ pub const Action = union(Key) { pub const KittyClipboard = osc.Command.KittyClipboardProtocol; pub const KittyDnd = osc.Command.KittyDndProtocol; + pub const KittyDesktopNotification = osc.Command.KittyDesktopNotification; }; /// Returns a type that can process a stream of tty control characters. @@ -2725,6 +2728,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.kitty_dnd, v); }, + .kitty_desktop_notification => |v| { + self.handler.vt(.kitty_desktop_notification, v); + }, + .unknown => |v| { @branchHint(.unlikely); self.handler.vt(.osc_unknown, v); @@ -2740,7 +2747,6 @@ pub fn Stream(comptime H: type) type { .conemu_output_environment_variable, .conemu_run_process, .kitty_text_sizing, - .kitty_desktop_notification, .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index aa6cf12164..a4c61d6e39 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -19,6 +19,7 @@ const kitty_clipboard = @import("kitty/clipboard.zig"); const kitty_color = @import("kitty/color.zig"); const paste_pkg = @import("paste.zig"); const kitty_dnd = @import("kitty/dnd.zig"); +const kitty_notification = @import("kitty/notification.zig"); const lib = @import("lib.zig"); const size_report = @import("size_report.zig"); const simd = @import("../simd/main.zig"); @@ -81,6 +82,9 @@ pub const Handler = struct { /// Heap-allocated since transactions are rare and short-lived. kitty_clipboard_write: ?*kitty_clipboard.WriteState = null, + /// A Kitty desktop notification (OSC 99) arriving in chunks. + kitty_notification: kitty_notification.State = .{}, + /// Kitty clipboard protocol (OSC 5522) session password grants, /// recorded when a clipboard_read or clipboard_write reply asks to /// remember the user's decision. @@ -457,6 +461,7 @@ pub const Handler = struct { pub fn deinit(self: *Handler) void { self.kittyClipboardAbort(); self.kitty_clipboard_grants.deinit(self.terminal.gpa()); + self.kitty_notification.deinit(self.terminal.gpa()); self.apc_handler.deinit(); self.dcs_handler.deinit(); } @@ -732,6 +737,9 @@ pub const Handler = struct { // Effect-based handlers .bell => self.bell(), .show_desktop_notification => self.desktopNotification(value), + .kitty_desktop_notification => self.kittyDesktopNotification(value) catch |err| { + log.warn("error handling kitty desktop notification err={}", .{err}); + }, .device_attributes => self.reportDeviceAttributes(value), .device_status => self.deviceStatus(value.request), .enquiry => self.reportEnquiry(), @@ -868,6 +876,30 @@ pub const Handler = struct { func(self, notification); } + /// A Kitty desktop notification (OSC 99): a finished one goes to the + /// same effect as OSC 9 and OSC 777. Without that effect nothing can + /// show it, so the sequence is ignored, the query included, and the + /// program falls back to whatever it does without the protocol. + fn kittyDesktopNotification( + self: *Handler, + v: Action.KittyDesktopNotification, + ) (Allocator.Error || std.Io.Writer.Error)!void { + const func = self.effects.desktop_notification orelse return; + + var stack = std.heap.stackFallback(128, self.terminal.gpa()); + const response_alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(response_alloc); + defer aw.deinit(); + + const done = try self.kitty_notification.handle( + self.terminal.gpa(), + &aw.writer, + v, + ); + if (aw.written().len > 0) self.writePty(aw.written()); + if (done) |n| func(self, .{ .title = n.title, .body = n.body }); + } + fn semanticPrompt(self: *Handler, cmd: osc.Command.SemanticPrompt) !void { try self.terminal.semanticPrompt(cmd); const func = self.effects.semantic_prompt orelse return; @@ -3785,6 +3817,67 @@ test "desktop_notification effect callback" { try testing.expectEqualStrings("Needs attention", S.last_body); } +test "kitty desktop notification through the desktop_notification effect" { + 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 title: [64]u8 = undefined; + var title_len: usize = 0; + var body: [64]u8 = undefined; + var body_len: usize = 0; + var pty: std.ArrayList(u8) = .empty; + + fn writePty(_: *Handler, data: []const u8) void { + pty.appendSlice(testing.allocator, data) catch @panic("OOM"); + } + + fn desktopNotification( + _: *Handler, + notification: Action.ShowDesktopNotification, + ) void { + count += 1; + @memcpy(title[0..notification.title.len], notification.title); + title_len = notification.title.len; + @memcpy(body[0..notification.body.len], notification.body); + body_len = notification.body.len; + } + }; + S.count = 0; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + // Without the effect nothing could show it, so even the query goes + // unanswered and the program falls back. + { + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + s.nextSlice("\x1B]99;i=q:p=?;\x1B\\\x1B]99;;Ignored\x1B\\"); + try testing.expectEqualStrings("", S.pty.items); + } + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.desktop_notification = &S.desktopNotification; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]99;i=q:p=?;\x07"); + try testing.expectEqualStrings("\x1B]99;i=q:p=?;o=always:p=title,body,?\x07", S.pty.items); + + // A title in two chunks, then its body, which is done. + s.nextSlice("\x1B]99;i=1:d=0;Build\x1B\\"); + s.nextSlice("\x1B]99;i=1:d=0:e=1;IGRvbmU=\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.count); + s.nextSlice("\x1B]99;i=1:p=body;All tests passed\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqualStrings("Build done", S.title[0..S.title_len]); + try testing.expectEqualStrings("All tests passed", S.body[0..S.body_len]); +} + test "progress_report effect callback" { 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 dbcb2d802d..ae6fa95dd5 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -374,6 +374,7 @@ pub const StreamHandler = struct { .title_push, .title_pop, .kitty_dnd, + .kitty_desktop_notification, .osc_unknown, => {}, }