From 6cadad06f468745651a6bb53e64d31cc8fae9e24 Mon Sep 17 00:00:00 2001 From: "dolzhenko.e4" Date: Thu, 13 Aug 2026 20:47:56 +0300 Subject: [PATCH 1/3] termio: preserve UTF-8 in desktop notification truncation --- src/termio/stream_handler.zig | 45 +++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ac269e7bc..12ba3b1cd 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -16,6 +16,17 @@ const posix = std.posix; const log = std.log.scoped(.io_handler); +/// Copy the longest valid UTF-8 prefix that fits in a sentinel-terminated +/// destination. Desktop notification strings are passed to platform APIs that +/// require valid UTF-8, so truncation must not split a multibyte codepoint. +fn copyUtf8Z(comptime capacity: usize, dst: *[capacity:0]u8, src: []const u8) void { + var len = @min(src.len, capacity); + while (len > 0 and !std.unicode.utf8ValidateSlice(src[0..len])) : (len -= 1) {} + + @memcpy(dst[0..len], src[0..len]); + dst[len] = 0; +} + /// This is used as the handler for the terminal.Stream type. This is /// stateful and is expected to live for the entire lifetime of the terminal. /// It is NOT VALID to stop a stream handler, create a new one, and use that @@ -1348,13 +1359,16 @@ pub const StreamHandler = struct { ) !void { var message = apprt.surface.Message{ .desktop_notification = undefined }; - const title_len = @min(title.len, message.desktop_notification.title.len); - @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); - message.desktop_notification.title[title_len] = 0; - - const body_len = @min(body.len, message.desktop_notification.body.len); - @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); - message.desktop_notification.body[body_len] = 0; + copyUtf8Z( + message.desktop_notification.title.len, + &message.desktop_notification.title, + title, + ); + copyUtf8Z( + message.desktop_notification.body.len, + &message.desktop_notification.body, + body, + ); self.surfaceMessageWriter(message); } @@ -1466,3 +1480,20 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +test "copyUtf8Z truncates at a UTF-8 codepoint boundary" { + var dst: [5:0]u8 = undefined; + + copyUtf8Z(dst.len, &dst, "abcdЯ"); + + try std.testing.expectEqualStrings("abcd", std.mem.sliceTo(&dst, 0)); + try std.testing.expect(std.unicode.utf8ValidateSlice(std.mem.sliceTo(&dst, 0))); +} + +test "copyUtf8Z preserves UTF-8 that fits" { + var dst: [5:0]u8 = undefined; + + copyUtf8Z(dst.len, &dst, "abcЯ"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} From ae6d97ea71b8ad4bb0d3837cc807d6ae097d4145 Mon Sep 17 00:00:00 2001 From: "dolzhenko.e4" Date: Fri, 14 Aug 2026 16:17:19 +0300 Subject: [PATCH 2/3] termio: avoid rescanning UTF-8 prefixes --- src/termio/stream_handler.zig | 59 ++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 12ba3b1cd..1267649fc 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -16,12 +16,22 @@ const posix = std.posix; const log = std.log.scoped(.io_handler); -/// Copy the longest valid UTF-8 prefix that fits in a sentinel-terminated -/// destination. Desktop notification strings are passed to platform APIs that -/// require valid UTF-8, so truncation must not split a multibyte codepoint. +/// UTF-8 continuation bytes occupy the range 0x80 through 0xBF. +fn isUtf8ContinuationByte(byte: u8) bool { + return switch (byte) { + 0x80...0xBF => true, + else => false, + }; +} + +/// Copy as much of `src` as fits, backing up from a UTF-8 continuation byte +/// so valid input is never truncated in the middle of a codepoint. fn copyUtf8Z(comptime capacity: usize, dst: *[capacity:0]u8, src: []const u8) void { var len = @min(src.len, capacity); - while (len > 0 and !std.unicode.utf8ValidateSlice(src[0..len])) : (len -= 1) {} + while (len > 0 and + len < src.len and + isUtf8ContinuationByte(src[len])) : (len -= 1) + {} @memcpy(dst[0..len], src[0..len]); dst[len] = 0; @@ -1481,13 +1491,46 @@ pub const StreamHandler = struct { } }; -test "copyUtf8Z truncates at a UTF-8 codepoint boundary" { +test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" { + var dst_1_byte: [1:0]u8 = undefined; + const src_ending_in_1_byte_codepoint = "ab"; + try std.testing.expect(!isUtf8ContinuationByte( + src_ending_in_1_byte_codepoint[dst_1_byte.len], + )); + copyUtf8Z(dst_1_byte.len, &dst_1_byte, src_ending_in_1_byte_codepoint); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0)); + + var dst_2_bytes: [2:0]u8 = undefined; + const src_ending_in_2_byte_codepoint = "aЯ"; + try std.testing.expect(isUtf8ContinuationByte( + src_ending_in_2_byte_codepoint[dst_2_bytes.len], + )); + copyUtf8Z(dst_2_bytes.len, &dst_2_bytes, src_ending_in_2_byte_codepoint); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0)); + + var dst_3_bytes: [3:0]u8 = undefined; + const src_ending_in_3_byte_codepoint = "a€"; + try std.testing.expect(isUtf8ContinuationByte( + src_ending_in_3_byte_codepoint[dst_3_bytes.len], + )); + copyUtf8Z(dst_3_bytes.len, &dst_3_bytes, src_ending_in_3_byte_codepoint); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0)); + + var dst_4_bytes: [4:0]u8 = undefined; + const src_ending_in_4_byte_codepoint = "a😀"; + try std.testing.expect(isUtf8ContinuationByte( + src_ending_in_4_byte_codepoint[dst_4_bytes.len], + )); + copyUtf8Z(dst_4_bytes.len, &dst_4_bytes, src_ending_in_4_byte_codepoint); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0)); +} + +test "copyUtf8Z keeps a complete codepoint at the truncation boundary" { var dst: [5:0]u8 = undefined; - copyUtf8Z(dst.len, &dst, "abcdЯ"); + copyUtf8Z(dst.len, &dst, "abcЯz"); - try std.testing.expectEqualStrings("abcd", std.mem.sliceTo(&dst, 0)); - try std.testing.expect(std.unicode.utf8ValidateSlice(std.mem.sliceTo(&dst, 0))); + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); } test "copyUtf8Z preserves UTF-8 that fits" { From 53c6fdbe7d53eb8c61f7af5e311d04956c4fe283 Mon Sep 17 00:00:00 2001 From: "dolzhenko.e4" Date: Sat, 15 Aug 2026 12:33:30 +0300 Subject: [PATCH 3/3] apprt: own desktop notification truncation Make the fixed-size desktop notification payload a named Message type and initialize it through a constructor. Keeping UTF-8 boundary truncation with the payload owns the buffer capacities and sentinel termination at the message boundary, while stream_handler only forwards the title and body. --- src/apprt/surface.zig | 124 ++++++++++++++++++++++++++++++++-- src/termio/stream_handler.zig | 88 +----------------------- 2 files changed, 120 insertions(+), 92 deletions(-) diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 3cb0016fa..4a0b8b377 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -16,6 +16,47 @@ pub const Message = union(enum) { /// we want this union to be. pub const WriteReq = MessageData(u8, 255); + /// A fixed-size desktop notification payload sent to the app thread. + pub const DesktopNotification = struct { + /// Desktop notification title. + title: [63:0]u8, + + /// Desktop notification body. + body: [255:0]u8, + + pub fn init(title: []const u8, body: []const u8) DesktopNotification { + var result: DesktopNotification = undefined; + copyUtf8Z(result.title.len, &result.title, title); + copyUtf8Z(result.body.len, &result.body, body); + return result; + } + + /// UTF-8 continuation bytes occupy the range 0x80 through 0xBF. + fn isUtf8ContinuationByte(byte: u8) bool { + return switch (byte) { + 0x80...0xBF => true, + else => false, + }; + } + + /// Copy as much of `src` as fits, backing up from a UTF-8 continuation + /// byte so valid input is never truncated in the middle of a codepoint. + fn copyUtf8Z( + comptime capacity: usize, + dst: *[capacity:0]u8, + src: []const u8, + ) void { + var len = @min(src.len, capacity); + while (len > 0 and + len < src.len and + isUtf8ContinuationByte(src[len])) : (len -= 1) + {} + + @memcpy(dst[0..len], src[0..len]); + dst[len] = 0; + } + }; + /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in /// the termio message so that we can more efficiently send strings @@ -52,13 +93,7 @@ pub const Message = union(enum) { child_exited: ChildExited, /// Show a desktop notification. - desktop_notification: struct { - /// Desktop notification title. - title: [63:0]u8, - - /// Desktop notification body. - body: [255:0]u8, - }, + desktop_notification: DesktopNotification, /// Health status change for the renderer. renderer_health: renderer.Health, @@ -195,3 +230,78 @@ pub fn newConfig( return copy; } + +test "DesktopNotification init" { + const notification = Message.DesktopNotification.init("Title", "Body"); + + try std.testing.expectEqualStrings("Title", std.mem.sliceTo(¬ification.title, 0)); + try std.testing.expectEqualStrings("Body", std.mem.sliceTo(¬ification.body, 0)); +} + +test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" { + const DesktopNotification = Message.DesktopNotification; + + var dst_1_byte: [1:0]u8 = undefined; + const src_ending_in_1_byte_codepoint = "ab"; + try std.testing.expect(!DesktopNotification.isUtf8ContinuationByte( + src_ending_in_1_byte_codepoint[dst_1_byte.len], + )); + DesktopNotification.copyUtf8Z( + dst_1_byte.len, + &dst_1_byte, + src_ending_in_1_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0)); + + var dst_2_bytes: [2:0]u8 = undefined; + const src_ending_in_2_byte_codepoint = "aЯ"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_2_byte_codepoint[dst_2_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_2_bytes.len, + &dst_2_bytes, + src_ending_in_2_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0)); + + var dst_3_bytes: [3:0]u8 = undefined; + const src_ending_in_3_byte_codepoint = "a€"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_3_byte_codepoint[dst_3_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_3_bytes.len, + &dst_3_bytes, + src_ending_in_3_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0)); + + var dst_4_bytes: [4:0]u8 = undefined; + const src_ending_in_4_byte_codepoint = "a😀"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_4_byte_codepoint[dst_4_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_4_bytes.len, + &dst_4_bytes, + src_ending_in_4_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0)); +} + +test "copyUtf8Z keeps a complete codepoint at the truncation boundary" { + var dst: [5:0]u8 = undefined; + + Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯz"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} + +test "copyUtf8Z preserves UTF-8 that fits" { + var dst: [5:0]u8 = undefined; + + Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯ"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 1267649fc..cf6bca909 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -16,27 +16,6 @@ const posix = std.posix; const log = std.log.scoped(.io_handler); -/// UTF-8 continuation bytes occupy the range 0x80 through 0xBF. -fn isUtf8ContinuationByte(byte: u8) bool { - return switch (byte) { - 0x80...0xBF => true, - else => false, - }; -} - -/// Copy as much of `src` as fits, backing up from a UTF-8 continuation byte -/// so valid input is never truncated in the middle of a codepoint. -fn copyUtf8Z(comptime capacity: usize, dst: *[capacity:0]u8, src: []const u8) void { - var len = @min(src.len, capacity); - while (len > 0 and - len < src.len and - isUtf8ContinuationByte(src[len])) : (len -= 1) - {} - - @memcpy(dst[0..len], src[0..len]); - dst[len] = 0; -} - /// This is used as the handler for the terminal.Stream type. This is /// stateful and is expected to live for the entire lifetime of the terminal. /// It is NOT VALID to stop a stream handler, create a new one, and use that @@ -1367,20 +1346,9 @@ pub const StreamHandler = struct { title: []const u8, body: []const u8, ) !void { - var message = apprt.surface.Message{ .desktop_notification = undefined }; - - copyUtf8Z( - message.desktop_notification.title.len, - &message.desktop_notification.title, - title, - ); - copyUtf8Z( - message.desktop_notification.body.len, - &message.desktop_notification.body, - body, - ); - - self.surfaceMessageWriter(message); + self.surfaceMessageWriter(.{ + .desktop_notification = .init(title, body), + }); } /// Send a report to the pty. @@ -1490,53 +1458,3 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; - -test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" { - var dst_1_byte: [1:0]u8 = undefined; - const src_ending_in_1_byte_codepoint = "ab"; - try std.testing.expect(!isUtf8ContinuationByte( - src_ending_in_1_byte_codepoint[dst_1_byte.len], - )); - copyUtf8Z(dst_1_byte.len, &dst_1_byte, src_ending_in_1_byte_codepoint); - try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0)); - - var dst_2_bytes: [2:0]u8 = undefined; - const src_ending_in_2_byte_codepoint = "aЯ"; - try std.testing.expect(isUtf8ContinuationByte( - src_ending_in_2_byte_codepoint[dst_2_bytes.len], - )); - copyUtf8Z(dst_2_bytes.len, &dst_2_bytes, src_ending_in_2_byte_codepoint); - try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0)); - - var dst_3_bytes: [3:0]u8 = undefined; - const src_ending_in_3_byte_codepoint = "a€"; - try std.testing.expect(isUtf8ContinuationByte( - src_ending_in_3_byte_codepoint[dst_3_bytes.len], - )); - copyUtf8Z(dst_3_bytes.len, &dst_3_bytes, src_ending_in_3_byte_codepoint); - try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0)); - - var dst_4_bytes: [4:0]u8 = undefined; - const src_ending_in_4_byte_codepoint = "a😀"; - try std.testing.expect(isUtf8ContinuationByte( - src_ending_in_4_byte_codepoint[dst_4_bytes.len], - )); - copyUtf8Z(dst_4_bytes.len, &dst_4_bytes, src_ending_in_4_byte_codepoint); - try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0)); -} - -test "copyUtf8Z keeps a complete codepoint at the truncation boundary" { - var dst: [5:0]u8 = undefined; - - copyUtf8Z(dst.len, &dst, "abcЯz"); - - try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); -} - -test "copyUtf8Z preserves UTF-8 that fits" { - var dst: [5:0]u8 = undefined; - - copyUtf8Z(dst.len, &dst, "abcЯ"); - - try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); -}