diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/termio/stream_handler.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/termio/stream_handler.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/termio/stream_handler.zig | 732 |
1 files changed, 562 insertions, 170 deletions
diff --git a/vendor/libghostty-vt/src/termio/stream_handler.zig b/vendor/libghostty-vt/src/termio/stream_handler.zig index 43c5d611..27e06dcb 100644 --- a/vendor/libghostty-vt/src/termio/stream_handler.zig +++ b/vendor/libghostty-vt/src/termio/stream_handler.zig @@ -2,7 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const xev = @import("../global.zig").xev; +const global = @import("../global.zig"); +const xev = global.xev; const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); @@ -40,12 +41,6 @@ pub const StreamHandler = struct { /// a repaint should happen. renderer_wakeup: xev.Async, - /// The default cursor state. This is used with CSI q. This is - /// set to true when we're currently in the default cursor state. - default_cursor: bool = true, - default_cursor_style: terminal.CursorStyle, - default_cursor_blink: ?bool, - /// The response to use for ENQ requests. The memory is owned by /// whoever owns StreamHandler. enquiry_response: []const u8, @@ -56,6 +51,10 @@ pub const StreamHandler = struct { /// The clipboard write access configuration. clipboard_write: configpkg.ClipboardAccess, + /// Maximum total decoded bytes per Kitty clipboard protocol + /// (OSC 5522) write transaction; exceeding it aborts with EFBIG. + clipboard_write_limit: usize, + //--------------------------------------------------------------- // Internal state @@ -73,6 +72,14 @@ pub const StreamHandler = struct { /// The tmux control mode viewer state. tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// Session password grants for the Kitty clipboard protocol. + /// Requests carrying a granted password skip the permission prompt. + kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{}, + + /// The in-flight Kitty clipboard protocol (OSC 5522) write + /// transaction, if any. + kitty_clipboard_write: ?*terminal.kitty.clipboard.WriteState = null, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -90,6 +97,8 @@ pub const StreamHandler = struct { pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + self.kittyClipboardWriteAbort(); + self.kitty_clipboard_grants.deinit(self.alloc); if (comptime tmux_enabled) tmux: { const viewer = self.tmux_viewer orelse break :tmux; viewer.deinit(); @@ -109,14 +118,10 @@ pub const StreamHandler = struct { pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { self.osc_color_report_format = config.osc_color_report_format; self.clipboard_write = config.clipboard_write; + self.clipboard_write_limit = config.clipboard_write_limit; self.enquiry_response = config.enquiry_response; - self.default_cursor_style = config.cursor_style; - self.default_cursor_blink = config.cursor_blink; - - // If our cursor is the default, then we update it immediately. - if (self.default_cursor) self.setCursorStyle(.default) catch |err| { - log.warn("failed to set default cursor style: {}", .{err}); - }; + self.terminal.setDefaultCursorStyle(config.cursor_style); + self.terminal.setDefaultCursorBlink(config.cursor_blink); // The config could have changed any of our colors so update mode 2031 self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); @@ -129,8 +134,8 @@ pub const StreamHandler = struct { // See messageWriter which has similar logic and explains why // we may have to do this. if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); + self.renderer_state.mutex.unlock(global.io()); + defer self.renderer_state.mutex.lockUncancelable(global.io()); _ = self.surface_mailbox.push(msg, .{ .forever = {} }); } } @@ -158,8 +163,8 @@ pub const StreamHandler = struct { // Instant would have blocked. Release the renderer mutex, // wake up the renderer to allow it to process the message, // and then try again. - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); + self.renderer_state.mutex.unlock(global.io()); + defer self.renderer_state.mutex.lockUncancelable(global.io()); self.renderer_wakeup.notify() catch |err| { // This is an EXTREMELY unlikely case. We still don't return // and attempt to send the message because its most likely @@ -242,7 +247,7 @@ pub const StreamHandler = struct { self.terminal.screens.active.cursor.y + 1 +| value.value, self.terminal.screens.active.cursor.x + 1, ), - .cursor_style => try self.setCursorStyle(value), + .cursor_style => self.terminal.setCursorStyle(value), .erase_display_below => self.terminal.eraseDisplay(.below, value), .erase_display_above => self.terminal.eraseDisplay(.above, value), .erase_display_complete => { @@ -362,10 +367,12 @@ pub const StreamHandler = struct { .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), + .kitty_clipboard => try self.kittyClipboard(value), // Unimplemented .title_push, .title_pop, + .kitty_dnd, => {}, } } @@ -403,7 +410,7 @@ pub const StreamHandler = struct { assert(self.tmux_viewer == null); const viewer = try self.alloc.create(terminal.tmux.Viewer); errdefer self.alloc.destroy(viewer); - viewer.* = try .init(self.alloc); + viewer.* = try .init(global.io(), self.alloc); errdefer viewer.deinit(); self.tmux_viewer = viewer; break :tmux; @@ -474,86 +481,26 @@ pub const StreamHandler = struct { }, .decrqss => |decrqss| { - var response: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&response); - const writer = stream.writer(); - - // Offset the stream position to just past the response prefix. - // We will write the "payload" (if any) below. If no payload is - // written then we send an invalid DECRPSS response. - const prefix_fmt = "\x1bP{d}$r"; - const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; - stream.pos = prefix_len; - - switch (decrqss) { - // Invalid or unhandled request - .none => {}, - - .sgr => { - const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); - - // printAttributes wrote into our buffer, so adjust the stream - // position - stream.pos += buf.len; - - try writer.writeByte('m'); - }, - - .decscusr => { - const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) { - .block => if (blink) 1 else 2, - .underline => if (blink) 3 else 4, - .bar => if (blink) 5 else 6, - - // Below here, the cursor styles aren't represented by - // DECSCUSR so we map it to some other style. - .block_hollow => if (blink) 1 else 2, - }; - try writer.print("{d} q", .{style}); - }, - - .decstbm => { - try writer.print("{d};{d}r", .{ - self.terminal.scrolling_region.top + 1, - self.terminal.scrolling_region.bottom + 1, - }); - }, - - .decslrm => { - // We only send a valid response when left and right - // margin mode (DECLRMM) is enabled. - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try writer.print("{d};{d}s", .{ - self.terminal.scrolling_region.left + 1, - self.terminal.scrolling_region.right + 1, - }); - } - }, - } - - // Our response is valid if we have a response payload - const valid = stream.pos > prefix_len; - - // Write the terminator - try writer.writeAll("\x1b\\"); - - // Write the response prefix into the buffer - _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); - const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); + var response: [terminal.dcs.Command.DECRQSS.max_response_bytes]u8 = undefined; + const encoded = try decrqss.encode(self.terminal, &response); + const msg = try termio.Message.writeReq( + self.alloc, + encoded, + ); self.messageWriter(msg); }, } } 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(self.alloc, kitty_cmd)) |resp| { + if (self.terminal.kittyGraphics(global.io(), self.alloc, kitty_cmd)) |resp| { var buf: [1024]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try resp.encode(&writer); @@ -687,7 +634,7 @@ pub const StreamHandler = struct { // their shell config when they render prompts to ensure the // cursor is exactly as they request. if (mode == .cursor_blinking and - self.default_cursor_blink != null) + self.terminal.cursor.default_blink != null) { return; } @@ -747,8 +694,10 @@ pub const StreamHandler = struct { const grid_size = self.size.grid(); self.terminal.resize( self.alloc, - grid_size.columns, - grid_size.rows, + .{ + .cols = grid_size.columns, + .rows = grid_size.rows, + }, ) catch |err| { log.err("error updating terminal size: {}", .{err}); }; @@ -773,6 +722,13 @@ pub const StreamHandler = struct { .size_report = .mode_2048, }), + .report_visibility => if (enabled) self.messageWriter(.{ + .visibility_report = .{ + .visible = self.terminal.flags.visible, + .force = true, + }, + }), + .focus_event => if (enabled) self.messageWriter(.{ .focused = self.terminal.flags.focused, }), @@ -889,55 +845,11 @@ pub const StreamHandler = struct { }, .color_scheme => self.messageWriter(.{ .color_scheme_report = .{ .force = true } }), - } - } - - pub fn setCursorStyle( - self: *StreamHandler, - style: terminal.CursorStyleReq, - ) !void { - // Assume we're setting to a non-default. - self.default_cursor = false; - - switch (style) { - .default => { - self.default_cursor = true; - self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style; - self.terminal.modes.set( - .cursor_blinking, - self.default_cursor_blink orelse true, - ); - }, - - .blinking_block => { - self.terminal.screens.active.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, true); - }, - .steady_block => { - self.terminal.screens.active.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_underline => { - self.terminal.screens.active.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_underline => { - self.terminal.screens.active.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_bar => { - self.terminal.screens.active.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_bar => { - self.terminal.screens.active.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, false); - }, + .visibility => self.messageWriter(.{ .visibility_report = .{ + .visible = self.terminal.flags.visible, + .force = true, + } }), } } @@ -972,6 +884,10 @@ pub const StreamHandler = struct { self.terminal.fullReset(); try self.setMouseShape(.text); + // Full reset clears Kitty clipboard session grants. + self.kitty_clipboard_grants.deinit(self.alloc); + self.kitty_clipboard_grants = .{}; + // Reset resets our palette so we report it for mode 2031. self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); @@ -979,6 +895,16 @@ pub const StreamHandler = struct { self.progressReport(.{ .state = .remove }); } + /// Record a Kitty clipboard protocol session grant so future + /// requests with this password skip the permission prompt. + pub fn kittyClipboardGrant( + self: *StreamHandler, + pw: []const u8, + dir: terminal.kitty.clipboard.Grants.Direction, + ) error{OutOfMemory}!void { + try self.kitty_clipboard_grants.grant(self.alloc, pw, dir, false); + } + pub fn queryKittyKeyboard(self: *StreamHandler) !void { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; @@ -1093,6 +1019,410 @@ pub const StreamHandler = struct { }); } + /// Handle one Kitty clipboard protocol (OSC 5522) packet. + fn kittyClipboard( + self: *StreamHandler, + v: terminal.osc.Command.KittyClipboardProtocol, + ) error{ OutOfMemory, WriteFailed }!void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Decode and validate the metadata. Malformed structure drops + // the packet without a response. Invalid decoded text on a write + // data or alias packet aborts an in-flight transaction. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + defer arena.deinit(); + const meta = (kitty_clipboard.Metadata.parse( + arena.allocator(), + v.metadata, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidValue => { + const state = self.kitty_clipboard_write orelse return; + switch (kitty_clipboard.Metadata.operation(v.metadata) orelse return) { + .wdata, .walias => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + v.terminator, + ), + .read, .write => {}, + } + return; + }, + }) orelse return; + + switch (meta.op) { + .read => try self.kittyClipboardRead( + &meta, + v.payload orelse "", + v.terminator, + ), + + .write => try self.kittyClipboardWriteBegin( + &meta, + v.terminator, + ), + + .wdata => try self.kittyClipboardWriteData( + &meta, + v.payload orelse "", + v.terminator, + ), + + .walias => try self.kittyClipboardWriteAlias( + &meta, + v.payload orelse "", + v.terminator, + ), + } + } + + fn kittyClipboardRead( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Everything about the request, including the request struct + // itself, lives in a single arena that crosses to the surface + // thread, which owns it from the moment the message is sent. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // The payload is the requested MIME list. A read request with + // an undecodable payload is dropped without any response, + // matching kitty. + const decoded = kitty_clipboard.Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => { + arena.deinit(); + return; + }, + }; + if (!decoded.isValidUtf8()) { + arena.deinit(); + return; + } + + // The targets type ('.') asks for the listing of available + // types rather than data. Requested types beyond the cap are + // dropped and simply never served, which is how the protocol + // reports an unavailable type anyway. + var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined; + var mimes_len: usize = 0; + var list = false; + var it = decoded.mimeIterator(); + while (it.next()) |mime| { + if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) { + list = true; + continue; + } + if (mimes_len == mimes_buf.len) continue; + mimes_buf[mimes_len] = mime; + mimes_len += 1; + } + + // Per the spec a password without a name is no password. A + // stored session grant for it lets the surface skip its + // permission prompt. + const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; + const granted = self.kittyClipboardReadGranted(pw, mimes_len); + + const req = try alloc.create(apprt.ClipboardRequest.KittyRead); + const mimes = try alloc.alloc([:0]const u8, mimes_len); + for (mimes_buf[0..mimes_len], mimes) |src, *dst| { + dst.* = try alloc.dupeZ(u8, src); + } + const id = try alloc.dupe(u8, meta.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, meta.name); + req.* = .{ + // The arena must be copied in last so it tracks every + // allocation above. + .arena = arena, + .location = switch (meta.loc) { + .primary => .primary, + else => .standard, + }, + .mimes = mimes, + .list = list, + .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, + .terminator = terminator, + }; + + self.surfaceMessageWriter(.{ .kitty_clipboard_read = req }); + } + + /// Whether a session grant covers a read request, consuming + /// one-time grants. A prompt-exempt request never consults the + /// grants: consuming a one-time paste password on a listing would + /// burn the grant before the follow-up data read. + fn kittyClipboardReadGranted( + self: *StreamHandler, + pw: []const u8, + mimes_len: usize, + ) bool { + if (terminal.kitty.clipboard.readPromptExempt(mimes_len)) return false; + return self.kitty_clipboard_grants.use(self.alloc, pw, .read); + } + + /// Begin a Kitty clipboard write transaction (type=write). + fn kittyClipboardWriteBegin( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // A new write silently replaces any in-flight transaction. + self.kittyClipboardWriteAbort(); + + // A write denied by policy can never succeed, so fail the + // transaction up front instead of spooling data we'd only + // throw away. Later wdata packets are ignored. + if (self.clipboard_write == .deny) { + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardWriteStatus( + .EPERM, + meta.id, + terminator, + ); + return; + } + + const state = try self.alloc.create(terminal.kitty.clipboard.WriteState); + errdefer self.alloc.destroy(state); + state.* = try .init(self.alloc, meta, .{ + .max_size = self.clipboard_write_limit, + }); + self.kitty_clipboard_write = state; + } + + /// Accumulate one wdata chunk, or commit the transaction when the + /// chunk carries no MIME type. + fn kittyClipboardWriteData( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // Data without a transaction is silently ignored, matching + // kitty. + const state = self.kitty_clipboard_write orelse return; + + // A wdata packet without a MIME type commits the transaction. + if (meta.mime.len == 0) return self.kittyClipboardWriteCommit( + state, + terminator, + ); + + state.data( + self.alloc, + meta, + payload, + ) catch |err| switch (err) { + // Failing to spool matches kitty's EIO for a failed buffer write. + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // Data over the write limit aborts the transaction and is + // reported to the client. + error.TooLarge => try self.kittyClipboardWriteFinish( + state, + .EFBIG, + terminator, + ), + + // An invalid base64 payload stream aborts the transaction. + error.Invalid => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + /// Register aliases from a walias packet. + fn kittyClipboardWriteAlias( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // Aliases without a transaction are silently ignored. Once a + // transaction exists, a missing target MIME type is invalid and + // aborts the transaction. + const state = self.kitty_clipboard_write orelse return; + if (meta.mime.len == 0) return self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ); + + state.alias( + self.alloc, + meta, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // An undecodable alias payload aborts the transaction. + error.Invalid => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + /// Commit the transaction: resolve the final contents and forward + /// them to the surface thread, which owns policy, any permission + /// prompt, the clipboard write itself, and the final reply. + fn kittyClipboardWriteCommit( + self: *StreamHandler, + state: *terminal.kitty.clipboard.WriteState, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + self.kittyClipboardWriteSend( + state, + terminator, + ) catch |err| switch (err) { + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // The last MIME type's payload stream was not correctly + // padded, which aborts the transaction. + error.Invalid => return try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + + // The transaction is complete; the surface owns the reply. + self.kittyClipboardWriteAbort(); + } + + fn kittyClipboardWriteSend( + self: *StreamHandler, + state: *terminal.kitty.clipboard.WriteState, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, Invalid }!void { + const committed = try state.commit(self.alloc); + defer committed.deinit(self.alloc); + + // Per the spec a password without a name is no password. A + // stored session grant for it lets the surface skip its + // permission prompt. + const pw: []const u8 = if (committed.name.len > 0) committed.pw else ""; + const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .write); + + // Everything about the request, including the request struct + // itself, lives in a single arena that crosses to the surface + // thread, which owns it from the moment the message is sent. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const req = try alloc.create(apprt.ClipboardRequest.KittyWrite); + const contents = try alloc.alloc( + apprt.ClipboardContent, + committed.contents.len, + ); + for (committed.contents, contents) |src, *dst| dst.* = .{ + .mime = try alloc.dupeZ(u8, src.mime), + .data = try alloc.dupeZ(u8, src.data), + }; + const id = try alloc.dupe(u8, committed.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, committed.name); + req.* = .{ + // The arena must be copied in last so it tracks every + // allocation above. + .arena = arena, + .location = switch (committed.loc) { + .primary => .primary, + else => .standard, + }, + .contents = contents, + .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, + .terminator = terminator, + }; + + self.surfaceMessageWriter(.{ .kitty_clipboard_write = req }); + } + + /// Answer the write transaction with its final status and drop it. + /// The id echoed is the one from the transaction's opening write + /// packet, matching kitty. + fn kittyClipboardWriteFinish( + self: *StreamHandler, + state: *const terminal.kitty.clipboard.WriteState, + status: terminal.kitty.clipboard.Status, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + defer self.kittyClipboardWriteAbort(); + try self.kittyClipboardWriteStatus(status, state.id, terminator); + } + + /// Reply to a write transaction with a single status packet. + fn kittyClipboardWriteStatus( + self: *StreamHandler, + status: terminal.kitty.clipboard.Status, + id: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + var stream: std.Io.Writer.Allocating = .init(self.alloc); + defer stream.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = .write, + .status = status, + .id = id, + .terminator = terminator, + }).encode(&stream.writer); + self.messageWriter(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try stream.toOwnedSlice(), + } }); + } + + /// Drop any in-flight write transaction without responding. + fn kittyClipboardWriteAbort(self: *StreamHandler) void { + if (self.kitty_clipboard_write) |state| { + state.deinit(self.alloc); + self.alloc.destroy(state); + self.kitty_clipboard_write = null; + } + } + fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, @@ -1175,22 +1505,18 @@ pub const StreamHandler = struct { return; } - var host_buffer: [std.Uri.host_name_max]u8 = undefined; + var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; const host = uri.getHost(&host_buffer) catch |err| switch (err) { error.UriMissingHost => { log.warn("OSC 7 uri must contain a hostname: {}", .{err}); return; }, - error.UriHostTooLong => { - log.warn("failed to get full hostname for OSC 7 validation: {}", .{err}); - return; - }, }; // OSC 7 is a little sketchy because anyone can send any value from // any host (such an SSH session). The best practice terminals follow // is to valid the hostname to be local. - const host_valid = internal_os.hostname.isLocal(host) catch |err| switch (err) { + const host_valid = internal_os.hostname.isLocal(host.bytes) catch |err| switch (err) { error.PermissionDenied, error.Unexpected, => { @@ -1199,7 +1525,7 @@ pub const StreamHandler = struct { }, }; if (!host_valid) { - log.warn("OSC 7 host ({s}) must be local", .{host}); + log.warn("OSC 7 host ({s}) must be local", .{host.bytes}); return; } @@ -1244,8 +1570,7 @@ pub const StreamHandler = struct { var fba: std.heap.FixedBufferAllocator = .init(&buffer); const alloc = fba.allocator(); - var response: std.ArrayListUnmanaged(u8) = .empty; - const writer = response.writer(alloc); + var response: std.Io.Writer.Allocating = .init(alloc); var it = requests.constIterator(0); while (it.next()) |req| { @@ -1392,7 +1717,7 @@ pub const StreamHandler = struct { switch (self.osc_color_report_format) { .@"16-bit" => switch (kind) { - .palette => |i| try writer.print( + .palette => |i| try response.writer.print( "\x1b]4;{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", .{ i, @@ -1401,7 +1726,7 @@ pub const StreamHandler = struct { @as(u16, color.b) * 257, }, ), - .dynamic => |dynamic| try writer.print( + .dynamic => |dynamic| try response.writer.print( "\x1b]{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", .{ @intFromEnum(dynamic), @@ -1414,7 +1739,7 @@ pub const StreamHandler = struct { }, .@"8-bit" => switch (kind) { - .palette => |i| try writer.print( + .palette => |i| try response.writer.print( "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", .{ i, @@ -1423,7 +1748,7 @@ pub const StreamHandler = struct { @as(u16, color.b), }, ), - .dynamic => |dynamic| try writer.print( + .dynamic => |dynamic| try response.writer.print( "\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", .{ @intFromEnum(dynamic), @@ -1438,15 +1763,15 @@ pub const StreamHandler = struct { .none => unreachable, } - try writer.writeAll(terminator.string()); + try response.writer.writeAll(terminator.string()); }, } } - if (response.items.len > 0) { + if (response.writer.end > 0) { // If any of the operations were reports, finalize the report // string and send it to the terminal. - const msg = try termio.Message.writeReq(self.alloc, response.items); + const msg = try termio.Message.writeReq(self.alloc, response.writer.buffered()); self.messageWriter(msg); } } @@ -1456,17 +1781,9 @@ pub const StreamHandler = struct { title: []const u8, body: []const u8, ) !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; - - self.surfaceMessageWriter(message); + self.surfaceMessageWriter(.{ + .desktop_notification = .init(title, body), + }); } /// Send a report to the pty. @@ -1576,3 +1893,78 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +test "kitty clipboard read: targets-only never consumes a one-time grant" { + const testing = std.testing; + + var handler: StreamHandler = undefined; + handler.alloc = testing.allocator; + handler.kitty_clipboard_grants = .{}; + defer handler.kitty_clipboard_grants.deinit(testing.allocator); + try handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true); + + // A listing request must not burn the one-time paste password... + try testing.expect(!handler.kittyClipboardReadGranted("otp", 0)); + // ...so the follow-up data read is still granted, exactly once. + try testing.expect(handler.kittyClipboardReadGranted("otp", 1)); + try testing.expect(!handler.kittyClipboardReadGranted("otp", 1)); +} + +test "kitty clipboard write: oversized text replies EFBIG" { + const testing = std.testing; + + var mailbox = try termio.Mailbox.initSPSC(testing.allocator); + defer mailbox.deinit(testing.allocator); + + var mutex: std.Io.Mutex = .init; + mutex.lockUncancelable(global.io()); + defer mutex.unlock(global.io()); + + var renderer_state: renderer.State = .{ + .mutex = &mutex, + .terminal = undefined, + }; + var handler: StreamHandler = undefined; + handler.alloc = testing.allocator; + handler.termio_mailbox = &mailbox; + handler.renderer_state = &renderer_state; + handler.clipboard_write = .allow; + handler.clipboard_write_limit = 4; + handler.kitty_clipboard_write = null; + defer handler.kittyClipboardWriteAbort(); + + const begin: terminal.kitty.clipboard.Metadata = .{ + .op = .write, + .id = "macos", + }; + try handler.kittyClipboardWriteBegin(&begin, .st); + const state = handler.kitty_clipboard_write.?; + try state.data( + testing.allocator, + &.{ .op = .wdata, .mime = "text/plain" }, + "SGVsbA==", // "Hell" + ); + try testing.expectError(error.TooLarge, state.data( + testing.allocator, + &.{ .op = .wdata, .mime = "text/plain" }, + "bw==", // "o" + )); + try handler.kittyClipboardWriteFinish(state, .EFBIG, .st); + try testing.expect(handler.kitty_clipboard_write == null); + + const response = mailbox.spsc.queue.pop(global.io()); + try testing.expect(response != null); + const msg = response.?; + defer msg.deinit(); + switch (msg) { + .write_alloc => |v| try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EFBIG:id=macos\x1B\\", + v.data, + ), + else => try testing.expect(false), + } + + // Teardown leaves no transaction that could be committed and + // forwarded to the macOS clipboard path. + try testing.expect(mailbox.spsc.queue.pop(global.io()) == null); +} |
