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 | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/termio')
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Exec.zig | 320 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Termio.zig | 206 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/Thread.zig | 24 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/mailbox.zig | 15 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/message.zig | 46 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/shell_integration.zig | 110 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/termio/stream_handler.zig | 732 |
7 files changed, 1024 insertions, 429 deletions
diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig index 0894f87b..01629171 100644 --- a/vendor/libghostty-vt/src/termio/Exec.zig +++ b/vendor/libghostty-vt/src/termio/Exec.zig @@ -9,7 +9,8 @@ const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; -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"); @@ -21,13 +22,13 @@ const shell_integration = @import("shell_integration.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); -const SegmentedPool = @import("../datastruct/main.zig").SegmentedPool; const ptypkg = @import("../pty.zig"); const Pty = ptypkg.Pty; -const EnvMap = std.process.EnvMap; +const EnvMap = std.process.Environ.Map; const PasswdEntry = internal_os.passwd.Entry; const windows = internal_os.windows; const ProcessInfo = @import("../pty.zig").ProcessInfo; +const compat_fd = @import("../lib/compat/fd.zig"); const log = std.log.scoped(.io_exec); @@ -98,7 +99,7 @@ pub fn threadEnter( // We're in the child. Nothing more we can do but abnormal exit. // The Command will output some additional information. - posix.exit(1); + std.process.exit(1); }; errdefer self.subprocess.stop(); @@ -117,13 +118,13 @@ pub fn threadEnter( errdefer if (process) |*p| p.deinit(); // Track our process start time for abnormal exits - const process_start = try std.time.Instant.now(); + const process_start: std.Io.Timestamp = .now(global.io(), .awake); // Create our pipe that we'll use to kill our read thread. // pipe[0] is the read end, pipe[1] is the write end. const pipe = try internal_os.pipe(); - errdefer posix.close(pipe[0]); - errdefer posix.close(pipe[1]); + errdefer _ = posix.system.close(pipe[0]); + errdefer _ = posix.system.close(pipe[1]); // Setup our stream so that we can write. var stream = xev.Stream.initFd(pty_fds.write); @@ -141,7 +142,7 @@ pub fn threadEnter( if (builtin.os.tag == .windows) ReadThread.threadMainWindows else ReadThread.threadMainPosix, .{ pty_fds.read, io, pipe[0] }, ); - read_thread.setName("io-reader") catch {}; + read_thread.setName(global.io(), "io-reader") catch {}; // Setup our threadata backend state to be our own td.backend = .{ .exec = .{ @@ -202,22 +203,23 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { // Quit our read thread after exiting the subprocess so that // we don't get stuck waiting for data to stop flowing if it is // a particularly noisy process. - _ = posix.write(exec.read_thread_pipe, "x") catch |err| switch (err) { - // BrokenPipe means that our read thread is closed already, - // which is completely fine since that is what we were trying - // to achieve. - error.BrokenPipe => {}, - - else => log.warn( - "error writing to read thread quit pipe err={}", - .{err}, + switch (posix.errno(posix.system.write(exec.read_thread_pipe, "x", 1))) { + .SUCCESS => {}, + + // EPIPE means that our read thread is closed already, which is + // completely fine since that is what we were trying to achieve. + .PIPE => {}, + + else => |e| log.warn( + "error writing to read thread quit pipe err=E{s}", + .{@tagName(e)}, ), - }; + } if (comptime builtin.os.tag == .windows) { // Interrupt the blocking read so the thread can see the quit message - if (windows.kernel32.CancelIoEx(exec.read_thread_fd, null) == 0) { - switch (windows.kernel32.GetLastError()) { + if (windows.exp.kernel32.CancelIoEx(exec.read_thread_fd, null) == windows.FALSE) { + switch (windows.GetLastError()) { .NOT_FOUND => {}, else => |err| log.warn("error interrupting read thread err={}", .{err}), } @@ -275,23 +277,18 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void { execdata.exited = true; // Determine how long the process was running for. - const runtime_ms: ?u64 = runtime: { - const process_end = std.time.Instant.now() catch break :runtime null; - const runtime_ns = process_end.since(execdata.start); - const runtime_ms = runtime_ns / std.time.ns_per_ms; - break :runtime runtime_ms; - }; - log.debug( - "child process exited status={} runtime={}ms", - .{ exit_code, runtime_ms orelse 0 }, + const runtime_ms: u64 = @max( + 0, + execdata.start.untilNow(global.io(), .awake).toMilliseconds(), ); + log.debug("child process exited status={} runtime={}ms", .{ exit_code, runtime_ms }); // We always notify the surface immediately that the child has // exited and some metadata about the exit. _ = td.surface_mailbox.push(.{ .child_exited = .{ .exit_code = exit_code, - .runtime_ms = runtime_ms orelse 0, + .runtime_ms = runtime_ms, }, }, .{ .forever = {} }); } @@ -372,8 +369,8 @@ fn termiosTimer( // If our password input state changed on the terminal then // we notify the surface. { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); + td.renderer_state.mutex.lockUncancelable(global.io()); + defer td.renderer_state.mutex.unlock(global.io()); const t = td.renderer_state.terminal; if (t.flags.password_input == password_input) { break :mode_change; @@ -420,8 +417,9 @@ pub fn queueWrite( // our cached buffers that we can queue to the stream. var i: usize = 0; while (i < data.len) { - const req = try exec.write_req_pool.getGrow(alloc); - const buf = try exec.write_buf_pool.getGrow(alloc); + const w = try exec.write_pool.create(alloc); + w.td = exec; + const buf = &w.buf; const slice = slice: { // The maximum end index is either the end of our data or // the end of our buffer, whichever is smaller. @@ -461,26 +459,25 @@ pub fn queueWrite( exec.write_stream.queueWrite( td.loop, &exec.write_queue, - req, + &w.req, .{ .slice = slice }, - termio.Exec.ThreadData, - exec, + ThreadData.Write, + w, ttyWrite, ); } } fn ttyWrite( - td_: ?*ThreadData, + w_: ?*ThreadData.Write, _: *xev.Loop, _: *xev.Completion, _: xev.Stream, _: xev.WriteBuffer, r: xev.WriteError!usize, ) xev.CallbackAction { - const td = td_.?; - td.write_req_pool.put(); - td.write_buf_pool.put(); + const w = w_.?; + w.td.write_pool.destroy(w); const d = r catch |err| { log.err("write error: {}", .{err}); @@ -494,12 +491,24 @@ fn ttyWrite( /// The thread local data for the exec implementation. pub const ThreadData = struct { - // The preallocation size for the write request pool. This should be big - // enough to satisfy most write requests. It must be a power of 2. - const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); + /// The state for a single queued pty write. The write request and + /// the buffer it writes from must both remain pointer-stable until + /// the write completes, so they're pooled together and checked out + /// per write. + pub const Write = struct { + /// Backpointer to the thread data so the write completion + /// callback can put this back into the pool. + td: *ThreadData, + + /// The libxev write request. + req: xev.WriteRequest, + + /// The buffer for the data being written. + buf: [64]u8, + }; /// Process start time and boolean of whether its already exited. - start: std.time.Instant, + start: std.Io.Timestamp, exited: bool = false, /// The data stream is the main IO for the pty. @@ -508,12 +517,9 @@ pub const ThreadData = struct { /// The process watcher process: ?xev.Process, - /// This is the pool of available (unused) write requests. If you grab + /// This is the pool of available (unused) write states. If you grab /// one from the pool, you must put it back when you're done! - write_req_pool: SegmentedPool(xev.WriteRequest, WRITE_REQ_PREALLOC) = .{}, - - /// The pool of available buffers for writing to the pty. - write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, + write_pool: std.heap.MemoryPool(Write) = .empty, /// The write queue for the data stream. write_queue: xev.WriteQueue = .{}, @@ -541,13 +547,12 @@ pub const ThreadData = struct { termios_mode: ptypkg.Mode = .{}, pub fn deinit(self: *ThreadData, alloc: Allocator) void { - posix.close(self.read_thread_pipe); + _ = posix.system.close(self.read_thread_pipe); - // Clear our write pools. We know we aren't ever going to do + // Clear our write pool. We know we aren't ever going to do // any more IO since we stop our data stream below so we can just // drop this. - self.write_req_pool.deinit(alloc); - self.write_buf_pool.deinit(alloc); + self.write_pool.deinit(alloc); // Stop our process watcher if (self.process) |*p| p.deinit(); @@ -671,10 +676,13 @@ const Subprocess = struct { } var exe_buf: [std.fs.max_path_bytes]u8 = undefined; - const exe_bin_path = std.fs.selfExePath(&exe_buf) catch |err| { + const exe_bin_path = exe_buf[0 .. std.process.executablePath( + global.io(), + &exe_buf, + ) catch |err| { log.warn("failed to get ghostty exe path err={}", .{err}); break :ghostty_path; - }; + }]; const exe_dir = std.fs.path.dirname(exe_bin_path) orelse break :ghostty_path; log.debug("appending ghostty bin to path dir={s}", .{exe_dir}); @@ -695,7 +703,7 @@ const Subprocess = struct { try env.put( "PATH", - try internal_os.appendEnv(alloc, path, exe_dir), + try appendEnv(alloc, path, exe_dir), ); } else { try env.put("PATH", exe_dir); @@ -713,7 +721,7 @@ const Subprocess = struct { if (std.fmt.bufPrint(&buf, "{s}/..", .{resources_dir})) |data_dir| { try env.put( xdg_data_dir_key, - try internal_os.appendEnv( + try appendEnv( alloc, env.get(xdg_data_dir_key) orelse "/usr/local/share:/usr/share", data_dir, @@ -730,7 +738,7 @@ const Subprocess = struct { // path instead of overriding all paths set by OS. try env.put( manpath_key, - try internal_os.appendEnvAlways( + try appendEnvAlways( alloc, env.get(manpath_key) orelse "", man_dir, @@ -749,7 +757,7 @@ const Subprocess = struct { // VTE_VERSION is set by gnome-terminal and other VTE-based terminals. // We don't want our child processes to think we're running under VTE. // This is not apprt-specific, so we do it here. - env.remove("VTE_VERSION"); + _ = env.orderedRemove("VTE_VERSION"); // Setup our shell integration, if we can. const shell_command: configpkg.Command = shell: { @@ -907,7 +915,7 @@ const Subprocess = struct { self.pty = pty; errdefer if (!in_child) { if (comptime builtin.os.tag != .windows) { - _ = posix.close(pty.slave); + _ = posix.system.close(pty.slave); } pty.deinit(); @@ -921,7 +929,7 @@ const Subprocess = struct { // Once our subcommand is started we can close the slave // side. This prevents the slave fd from being leaked to // future children. - _ = posix.close(pty.slave); + _ = posix.system.close(pty.slave); } // Successful start we can clear out some memory. @@ -944,8 +952,12 @@ const Subprocess = struct { // // https://docs.flatpak.org/en/latest/sandbox-permissions.html#reserved-paths log.info("flatpak detected, will use host command to verify cwd access", .{}); - const dev_null = try std.fs.cwd().openFile("/dev/null", .{ .mode = .read_write }); - defer dev_null.close(); + const dev_null = try std.Io.Dir.cwd().openFile( + global.io(), + "/dev/null", + .{ .mode = .read_write }, + ); + defer dev_null.close(global.io()); var cmd: internal_os.FlatpakHostCommand = .{ .argv = &[_][]const u8{ "/bin/sh", @@ -966,7 +978,7 @@ const Subprocess = struct { break :cwd proposed; } - if (std.fs.cwd().access(proposed, .{})) { + if (std.Io.Dir.cwd().access(global.io(), proposed, .{})) { break :cwd proposed; } else |err| { log.warn("cannot access cwd, ignoring: {}", .{err}); @@ -1011,9 +1023,18 @@ const Subprocess = struct { .args = self.args, .env = if (self.env) |*env| env else null, .cwd = cwd, - .stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, - .stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave }, + .stdin = if (builtin.os.tag == .windows) null else .{ + .handle = pty.slave, + .flags = .{ .nonblocking = false }, + }, + .stdout = if (builtin.os.tag == .windows) null else .{ + .handle = pty.slave, + .flags = .{ .nonblocking = false }, + }, + .stderr = if (builtin.os.tag == .windows) null else .{ + .handle = pty.slave, + .flags = .{ .nonblocking = false }, + }, .pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {}, .os_pre_exec = switch (comptime builtin.os.tag) { .windows => null, @@ -1140,8 +1161,8 @@ const Subprocess = struct { if (command.pid) |pid| { switch (builtin.os.tag) { .windows => { - if (windows.kernel32.TerminateProcess(pid, 0) == 0) { - return windows.unexpectedError(windows.kernel32.GetLastError()); + if (windows.exp.kernel32.TerminateProcess(pid, 0) == windows.FALSE) { + return windows.unexpectedError(windows.GetLastError()); } _ = try command.wait(false); @@ -1182,10 +1203,14 @@ const Subprocess = struct { // The gist is that it lets us detect when children // are still alive without blocking so that we can // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - log.debug("waitpid result={}", .{res.pid}); - if (res.pid != 0) break; - std.Thread.sleep(10 * std.time.ns_per_ms); + const res_pid = while (true) { + const rc = posix.system.waitpid(pid, null, std.c.W.NOHANG); + if (posix.errno(rc) == .INTR) continue; + break rc; + }; + log.debug("waitpid result={}", .{res_pid}); + if (res_pid != 0) break; + try std.Io.sleep(global.io(), .fromMilliseconds(10), .awake); } } @@ -1205,7 +1230,7 @@ const Subprocess = struct { const pgid = c.getpgid(pid); if (pgid == my_pgid) { log.warn("pgid is our own, retrying", .{}); - std.Thread.sleep(10 * std.time.ns_per_ms); + std.Io.sleep(global.io(), .fromMilliseconds(10), .awake) catch {}; continue; } @@ -1336,15 +1361,15 @@ pub const ReadThread = struct { /// stage at a time, so buffer contents need no locking. Only the /// ring metadata is guarded by the mutex. const Pipeline = struct { - mutex: std.Thread.Mutex = .{}, + mutex: std.Io.Mutex = .init, /// Signaled when a batch is published or the gather stage is /// done. Waited on by the parse stage. - batch_ready: std.Thread.Condition = .{}, + batch_ready: std.Io.Condition = .init, /// Signaled when a batch has been consumed. Waited on by the /// gather stage when all buffers are in flight (backpressure). - slot_free: std.Thread.Condition = .{}, + slot_free: std.Io.Condition = .init, /// The number of valid bytes in each buffer. Set at publish /// time by the gather stage, read by the parse stage. @@ -1385,7 +1410,7 @@ pub const ReadThread = struct { fn threadMainPosix(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. - defer posix.close(quit); + defer _ = posix.system.close(quit); // Right now, on Darwin, `std.Thread.setName` can only name the current // thread, and we have no way to get the current thread from within it, @@ -1419,7 +1444,7 @@ pub const ReadThread = struct { // can't create it we still run correctly, bridge polls are // just bounded by their timeout instead of being interrupted // when the parse stage goes idle. - if (posix.pipe2(.{ + if (compat_fd.pipe2(.{ .CLOEXEC = true, .NONBLOCK = true, })) |fds| { @@ -1429,8 +1454,8 @@ pub const ReadThread = struct { log.warn("read thread failed to create idle pipe err={}", .{err}); } defer if (pipeline.idle_read_fd >= 0) { - posix.close(pipeline.idle_read_fd); - posix.close(pipeline.idle_write_fd); + compat_fd.close(pipeline.idle_read_fd); + compat_fd.close(pipeline.idle_write_fd); }; const gather_thread = std.Thread.spawn( @@ -1446,7 +1471,7 @@ pub const ReadThread = struct { }; defer gather_thread.join(); if (comptime !builtin.os.tag.isDarwin()) { - gather_thread.setName("io-gather") catch {}; + gather_thread.setName(global.io(), "io-gather") catch {}; } // This thread is the parse stage. We consume batches in ring @@ -1454,11 +1479,11 @@ pub const ReadThread = struct { // the ring is drained. while (true) { const batch: []const u8 = batch: { - pipeline.mutex.lock(); - defer pipeline.mutex.unlock(); + pipeline.mutex.lockUncancelable(global.io()); + defer pipeline.mutex.unlock(global.io()); while (pipeline.count == 0) { if (pipeline.done) return; - pipeline.batch_ready.wait(&pipeline.mutex); + pipeline.batch_ready.waitUncancelable(global.io(), &pipeline.mutex); } const slot = pipeline.tail; break :batch pipeline.bufs[slot][0..pipeline.lens[slot]]; @@ -1469,27 +1494,27 @@ pub const ReadThread = struct { io.processOutput(batch); { - pipeline.mutex.lock(); + pipeline.mutex.lockUncancelable(global.io()); pipeline.tail = (pipeline.tail + 1) % buffer_count; pipeline.count -= 1; const wake = pipeline.count == 0 and pipeline.bridging and pipeline.idle_write_fd >= 0; - pipeline.mutex.unlock(); - pipeline.slot_free.signal(); + pipeline.mutex.unlock(global.io()); + pipeline.slot_free.signal(global.io()); // We ran out of batches while the gather stage is // bridging a refill gap: interrupt its poll so it // delivers what it has instead of sleeping out the // timeout while we sit idle. if (wake) { - _ = posix.write(pipeline.idle_write_fd, "i") catch {}; + _ = posix.system.write(pipeline.idle_write_fd, "i", 1); } } // Batch boundary: hand the renderer state mutex off if // the renderer is waiting. See renderer.State.lockDemand. - io.renderer_state.yieldToDemand(); + io.renderer_state.yieldToDemand(global.io()); } } @@ -1506,10 +1531,10 @@ pub const ReadThread = struct { // However we exit, tell the parse stage the stream is over so // it drains the ring and joins us. defer { - pipeline.mutex.lock(); + pipeline.mutex.lockUncancelable(global.io()); pipeline.done = true; - pipeline.mutex.unlock(); - pipeline.batch_ready.signal(); + pipeline.mutex.unlock(global.io()); + pipeline.batch_ready.signal(global.io()); } // The fds we poll: data on the pty, our quit notification, @@ -1528,16 +1553,16 @@ pub const ReadThread = struct { // we should stop reading and let the kernel queue exert // backpressure on the child. const buf: *[buffer_capacity]u8 = buf: { - pipeline.mutex.lock(); - defer pipeline.mutex.unlock(); + pipeline.mutex.lockUncancelable(global.io()); + defer pipeline.mutex.unlock(global.io()); while (pipeline.count == buffer_count) { - pipeline.slot_free.wait(&pipeline.mutex); + pipeline.slot_free.waitUncancelable(global.io(), &pipeline.mutex); } break :buf &pipeline.bufs[pipeline.head]; }; var total: usize = 0; - var bridge_start: ?std.time.Instant = null; + var bridge_start: ?std.Io.Timestamp = null; var spins: usize = 0; var fatal = false; @@ -1566,10 +1591,9 @@ pub const ReadThread = struct { // Still dry, so we want to sleep in poll for // the next refill, within our latency budget. - const now = std.time.Instant.now() catch - break :gather; + const now = std.Io.Timestamp.now(global.io(), .awake); if (bridge_start) |start| { - if (now.since(start) >= gather_budget_ns) + if (start.durationTo(now).toNanoseconds() >= gather_budget_ns) break :gather; } else bridge_start = now; @@ -1587,8 +1611,8 @@ pub const ReadThread = struct { // the idle wake so it can interrupt our poll // the moment that changes. { - pipeline.mutex.lock(); - defer pipeline.mutex.unlock(); + pipeline.mutex.lockUncancelable(global.io()); + defer pipeline.mutex.unlock(global.io()); if (pipeline.count == 0) break :gather; pipeline.bridging = true; } @@ -1671,12 +1695,12 @@ pub const ReadThread = struct { // Publish the batch (if any) to the parse stage and rotate // to the next buffer. if (total > 0) { - pipeline.mutex.lock(); + pipeline.mutex.lockUncancelable(global.io()); pipeline.lens[pipeline.head] = total; pipeline.head = (pipeline.head + 1) % buffer_count; pipeline.count += 1; - pipeline.mutex.unlock(); - pipeline.batch_ready.signal(); + pipeline.mutex.unlock(global.io()); + pipeline.batch_ready.signal(global.io()); } if (fatal) return; @@ -1710,8 +1734,8 @@ pub const ReadThread = struct { /// Clears the bridging flag armed before a bridge poll, closing /// the window in which the parse stage writes idle wakes. fn clearBridging(pipeline: *Pipeline) void { - pipeline.mutex.lock(); - defer pipeline.mutex.unlock(); + pipeline.mutex.lockUncancelable(global.io()); + defer pipeline.mutex.unlock(global.io()); pipeline.bridging = false; } @@ -1730,30 +1754,29 @@ pub const ReadThread = struct { /// Sets the fd to non-blocking mode. Returns false on failure. fn setNonblock(fd: posix.fd_t) bool { - const flags = posix.fcntl( - fd, - posix.F.GETFL, - 0, - ) catch |err| { - log.warn("read thread failed to get flags err={}", .{err}); + const flags = posix.system.fcntl(fd, posix.F.GETFL); + if (flags == -1) { + log.warn("read thread failed to get flags err=E{s}", .{@tagName(posix.errno(-1))}); return false; - }; + } - _ = posix.fcntl( + switch (posix.errno(posix.system.fcntl( fd, posix.F.SETFL, flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), - ) catch |err| { - log.warn("read thread failed to set flags err={}", .{err}); - return false; - }; - + ))) { + .SUCCESS => {}, + else => |e| { + log.warn("read thread failed to set flags err=E{s}", .{@tagName(e)}); + return false; + }, + } return true; } fn threadMainWindows(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. - defer posix.close(quit); + defer _ = posix.system.close(quit); // Setup our crash metadata crash.sentry.thread_state = .{ @@ -1766,8 +1789,8 @@ pub const ReadThread = struct { while (true) { while (true) { var n: windows.DWORD = 0; - if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { - const err = windows.kernel32.GetLastError(); + if (windows.exp.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == windows.FALSE) { + const err = windows.GetLastError(); switch (err) { // Check for a quit signal .OPERATION_ABORTED => break, @@ -1784,12 +1807,12 @@ pub const ReadThread = struct { // See threadMainPosix: hand the renderer state mutex // off if the renderer is waiting, since this loop // would otherwise starve it under heavy output. - io.renderer_state.yieldToDemand(); + io.renderer_state.yieldToDemand(global.io()); } var quit_bytes: windows.DWORD = 0; - if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { - const err = windows.kernel32.GetLastError(); + if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == windows.FALSE) { + const err = windows.GetLastError(); log.err("quit pipe reader error err={}", .{err}); unreachable; } @@ -1837,16 +1860,16 @@ fn execCommand( }; const hush = if (passwd.home) |home| hush: { - var dir = std.fs.openDirAbsolute(home, .{}) catch |err| { + var dir = std.Io.Dir.openDirAbsolute(global.io(), home, .{}) catch |err| { log.warn( "failed to open home dir, not checking for hushlogin err={}", .{err}, ); break :hush false; }; - defer dir.close(); + defer dir.close(global.io()); - break :hush if (dir.access(".hushlogin", .{})) true else |_| false; + break :hush if (dir.access(global.io(), ".hushlogin", .{})) true else |_| false; } else false; // If we made it this far we're going to start building @@ -1945,7 +1968,7 @@ fn execCommand( return switch (command) { // We need to clone the command since there's no guarantee the config remains valid. - .direct => |_| (try command.clone(alloc)).direct, + .direct => (try command.clone(alloc)).direct, .shell => |v| shell: { var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 4); @@ -1973,7 +1996,7 @@ fn execCommand( // Other values are passed as-is and resolved by // `internal_os.path.expand` in Command.startWindows. const argv0 = if (std.ascii.eqlIgnoreCase(v, "cmd.exe")) - std.process.getEnvVarOwned(alloc, "COMSPEC") catch + global.environ().getAlloc(alloc, "COMSPEC") catch try alloc.dupe(u8, v) else try alloc.dupe(u8, v); @@ -2002,6 +2025,37 @@ fn execCommand( }; } +/// Append a value to an environment variable such as PATH. +/// The returned value is always allocated so it must be freed. +fn appendEnv( + alloc: Allocator, + current: []const u8, + value: []const u8, +) Allocator.Error![]u8 { + // If there is no prior value, we return it as-is + if (current.len == 0) return try alloc.dupe(u8, value); + + // Otherwise we must prefix. + return try appendEnvAlways(alloc, current, value); +} + +/// Always append value to environment, even when it is empty. +/// This is useful because some env vars (like MANPATH) want there +/// to be an empty prefix to preserve existing values. +/// +/// The returned value is always allocated so it must be freed. +fn appendEnvAlways( + alloc: Allocator, + current: []const u8, + value: []const u8, +) Allocator.Error![]u8 { + return try std.fmt.allocPrint(alloc, "{s}{c}{s}", .{ + current, + std.fs.path.delimiter, + value, + }); +} + /// Get information about the process(es) running within the backend. Returns /// `null` if there was an error getting the information or the information is /// not available on a particular platform. @@ -2190,7 +2244,7 @@ test "execCommand windows: bare cmd.exe resolves via COMSPEC" { try testing.expectEqual(1, result.len); // Expect COMSPEC if available, otherwise the documented fallback. - const expected = std.process.getEnvVarOwned(alloc, "COMSPEC") catch + const expected = testing.environ.getAlloc(alloc, "COMSPEC") catch try alloc.dupe(u8, "C:\\Windows\\System32\\cmd.exe"); try testing.expectEqualStrings(expected, result[0]); } diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig index 1b08c5e9..d08f2e5f 100644 --- a/vendor/libghostty-vt/src/termio/Termio.zig +++ b/vendor/libghostty-vt/src/termio/Termio.zig @@ -8,18 +8,20 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const EnvMap = std.process.EnvMap; +const EnvMap = std.process.Environ.Map; const posix = std.posix; const termio = @import("../termio.zig"); const StreamHandler = @import("stream_handler.zig").StreamHandler; const terminalpkg = @import("../terminal/main.zig"); -const xev = @import("../global.zig").xev; +const global = @import("../global.zig"); +const xev = global.xev; const renderer = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); const internal_os = @import("../os/main.zig"); const windows = internal_os.windows; const configpkg = @import("../config.zig"); const ProcessInfo = @import("../pty.zig").ProcessInfo; +const compat_file = @import("../lib/compat/file.zig"); const log = std.log.scoped(.io_exec); @@ -65,7 +67,7 @@ terminal_stream: StreamHandler.Stream, /// Last time the cursor was reset. This is used to prevent message /// flooding with cursor resets. -last_cursor_reset: ?std.time.Instant = null, +last_cursor_reset: ?std.Io.Timestamp = null, /// State we have for thread enter. This may be null if we don't need /// to keep track of any state or if its already been freed. @@ -117,15 +119,18 @@ const ThreadEnterState = struct { ) (Allocator.Error || error{InputNotFound})![]const Input { const alloc = self.arena.allocator(); - var input = try alloc.alloc( - Input, + var inputs: std.ArrayList(Input) = try .initCapacity( + alloc, self.input.list.items.len, ); - for (self.input.list.items, 0..) |item, i| { - input[i] = switch (item) { - .raw => |v| .{ .string = try alloc.dupe(u8, v) }, + errdefer for (inputs.items) |item| item.deinit(); + + for (self.input.list.items) |item| { + inputs.appendAssumeCapacity(switch (item) { + .raw => |v| .{ .string = v }, .path => |path| file: { - const f = std.fs.cwd().openFile( + const f = std.Io.Dir.cwd().openFile( + global.io(), path, .{}, ) catch |err| { @@ -138,15 +143,22 @@ const ThreadEnterState = struct { break :file .{ .file = f }; }, - }; + }); } - return input; + return inputs.items; } const Input = union(enum) { string: []const u8, - file: std.fs.File, + file: std.Io.File, + + fn deinit(self: Input) void { + switch (self) { + .string => {}, + .file => |f| f.close(global.io()), + } + } }; }; @@ -165,6 +177,7 @@ pub const DerivedConfig = struct { background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, clipboard_write: configpkg.ClipboardAccess, + clipboard_write_limit: usize, enquiry_response: []const u8, conditional_state: configpkg.ConditionalState, @@ -201,6 +214,7 @@ pub const DerivedConfig = struct { .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", .clipboard_write = config.@"clipboard-write", + .clipboard_write_limit = config.@"clipboard-write-limit-bytes".value, .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), .conditional_state = config._conditional_state, @@ -238,13 +252,16 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }; // Create our terminal - var term = try terminalpkg.Terminal.init(alloc, opts: { + var term = try terminalpkg.Terminal.init(global.io(), alloc, opts: { const grid_size = opts.size.grid(); break :opts .{ .cols = grid_size.columns, .rows = grid_size.rows, - .max_scrollback = opts.full_config.@"scrollback-limit", + .max_scrollback_bytes = opts.full_config.@"scrollback-limit-bytes".optional(), + .max_scrollback_lines = opts.full_config.@"scrollback-limit-lines".optional(), .default_modes = default_modes, + .default_cursor_style = opts.config.cursor_style, + .default_cursor_blink = opts.config.cursor_blink, .colors = .{ .background = .init(opts.config.background.toTerminalRGB()), .foreground = .init(opts.config.foreground.toTerminalRGB()), @@ -253,16 +270,17 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { const rgb = color.toTerminalRGB() orelse break :cursor .unset; break :cursor .init(rgb); }, - .palette = .init(opts.config.palette), + .palette = .default, }, .kitty_image_storage_limit = opts.config.image_storage_limit, - .kitty_image_loading_limits = .all, + .kitty_image_loading_limits = .allWithTempDir(global.tmpDirPath()), }; }); errdefer term.deinit(alloc); - // Set our default cursor style - term.screens.active.cursor.cursor_style = opts.config.cursor_style; + // The default palette may be an allocator-owned copy, so it is set + // once the terminal owns its memory and can release it on deinit. + try term.colors.palette.changeDefault(alloc, opts.config.palette); // Setup our terminal size in pixels for certain requests. term.width_px = term.cols * opts.size.cell.width; @@ -285,9 +303,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .terminal = &self.terminal, .osc_color_report_format = opts.config.osc_color_report_format, .clipboard_write = opts.config.clipboard_write, + .clipboard_write_limit = opts.config.clipboard_write_limit, .enquiry_response = opts.config.enquiry_response, - .default_cursor_style = opts.config.cursor_style, - .default_cursor_blink = opts.config.cursor_blink, }; const thread_enter_state = try ThreadEnterState.create( @@ -306,7 +323,10 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = opts.size, .backend = backend, .mailbox = opts.mailbox, - .terminal_stream = .initAlloc(alloc, handler), + .terminal_stream = .init(.{ + .allocator = alloc, + .handler = handler, + }), .thread_enter_state = thread_enter_state, }; } @@ -342,6 +362,9 @@ pub fn threadEnter( try v.prepareInput() else null; + defer if (inputs) |items| { + for (items) |input| input.deinit(); + }; data.* = .{ .alloc = self.alloc, @@ -362,19 +385,21 @@ pub fn threadEnter( log.warn("failed to queue input string err={}", .{err}); return error.InputFailed; }, - .file => |f| self.queueWrite( - data, - f.readToEndAlloc( + .file => |f| { + const contents = compat_file.readToEndAlloc( + f, self.alloc, 10 * 1024 * 1024, // 10 MiB max ) catch |err| { log.warn("failed to read input file err={}", .{err}); return error.InputFailed; - }, - false, - ) catch |err| { - log.warn("failed to queue input file err={}", .{err}); - return error.InputFailed; + }; + defer self.alloc.free(contents); + + self.queueWrite(data, contents, false) catch |err| { + log.warn("failed to queue input file err={}", .{err}); + return error.InputFailed; + }; }, }; } @@ -422,8 +447,8 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // The remainder of this function is modifying terminal state or // the read thread data, all of which requires holding the renderer // state lock. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // Deinit our old config. We do this in the lock because the // stream handler may be referencing the old config (i.e. enquiry resp) @@ -442,8 +467,16 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // - command, working-directory: we never restart the underlying // process so we don't care or need to know about these. - // Update the default palette. - self.terminal.colors.palette.changeDefault(config.palette); + // Update the default palette. A config change must not fail here, so + // if we can't allocate the copy of the configured palette we fall back + // to the built-in default, which never allocates. + self.terminal.colors.palette.changeDefault( + self.alloc, + config.palette, + ) catch |err| { + log.warn("error changing default palette, using built-in default err={}", .{err}); + self.terminal.colors.palette.resetDefault(self.alloc); + }; self.terminal.flags.dirty.palette = true; // Update all our other colors @@ -455,8 +488,8 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi }; // Set the image limits - try self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit); - self.terminal.setKittyGraphicsLoadingLimits(.all); + self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit); + self.terminal.setKittyGraphicsLoadingLimits(.allWithTempDir(global.tmpDirPath())); } /// Resize the terminal. @@ -473,24 +506,22 @@ pub fn resize( // Enter the critical area that we want to keep small { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // Update the size of our terminal state try self.terminal.resize( self.alloc, - grid_size.columns, - grid_size.rows, + .{ + .cols = grid_size.columns, + .rows = grid_size.rows, + .cell_size_px = .{ + .width = self.size.cell.width, + .height = self.size.cell.height, + }, + }, ); - // Update our pixel sizes - self.terminal.width_px = grid_size.columns * self.size.cell.width; - self.terminal.height_px = grid_size.rows * self.size.cell.height; - - // Disable synchronized output mode so that we show changes - // immediately for a resize. This is allowed by the spec. - self.terminal.modes.set(.synchronized_output, false); - // If we have size reporting enabled we need to send a report. if (self.terminal.modes.get(.in_band_size_reports)) { try self.sizeReportLocked(td, .mode_2048); @@ -498,14 +529,14 @@ pub fn resize( } // Mail the renderer so that it can update the GPU and re-render - _ = self.renderer_mailbox.push(.{ .resize = size }, .{ .forever = {} }); + _ = self.renderer_mailbox.push(global.io(), .{ .resize = size }, .{ .forever = {} }); self.renderer_wakeup.notify() catch {}; } /// Make a size report. pub fn sizeReport(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); try self.sizeReportLocked(td, style); } @@ -534,8 +565,8 @@ fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeRe /// Reset the synchronized output mode. This is usually called by timer /// expiration from the termio thread. pub fn resetSynchronizedOutput(self: *Termio) void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.terminal.modes.set(.synchronized_output, false); self.renderer_wakeup.notify() catch {}; } @@ -543,8 +574,8 @@ pub fn resetSynchronizedOutput(self: *Termio) void { /// Clear the screen. pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // If we're on the alternate screen, we do not clear. Since this is an // emulator-level screen clear, this messes up the running programs @@ -573,6 +604,7 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // all of them. We delete all of them for now but if this behavior // isn't fully correct we should fix this later. self.terminal.screens.active.kitty_images.delete( + self.terminal.io(), self.terminal.screens.active.alloc, &self.terminal, .{ .all = true }, @@ -600,16 +632,16 @@ pub fn scrollViewport( self: *Termio, scroll: terminalpkg.Terminal.ScrollViewport, ) void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.terminal.scrollViewport(scroll); } /// Jump the viewport to the prompt. pub fn jumpToPrompt(self: *Termio, delta: isize) !void { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.terminal.screens.active.scroll(.{ .delta_prompt = delta }); } @@ -618,9 +650,9 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { /// Called when focus is gained or lost (when focus events are enabled) pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { - self.renderer_state.mutex.lock(); + self.renderer_state.mutex.lockUncancelable(global.io()); const focus_event = self.renderer_state.terminal.modes.get(.focus_event); - self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.unlock(global.io()); // If we have focus events enabled, we send the focus event. if (focus_event) { @@ -643,8 +675,8 @@ pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { pub fn processOutput(self: *Termio, buf: []const u8) void { // We are modifying terminal state from here on out and we need // the lock to grab our read data. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.processOutputLocked(buf); } @@ -657,19 +689,18 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { // non-blink state so it is rendered if visible. If we're under // HEAVY read load, we don't want to send a ton of these so we // use a timer under the covers - if (std.time.Instant.now()) |now| cursor_reset: { + const now = std.Io.Timestamp.now(global.io(), .awake); + cursor_reset: { if (self.last_cursor_reset) |last| { - if (now.since(last) <= (500 * std.time.ns_per_ms)) { + if (last.durationTo(now).toMilliseconds() <= 500) { break :cursor_reset; } } self.last_cursor_reset = now; - _ = self.renderer_mailbox.push(.{ + _ = self.renderer_mailbox.push(global.io(), .{ .reset_cursor_blink = {}, }, .{ .instant = {} }); - } else |err| { - log.warn("failed to get current time err={}", .{err}); } // If we have an inspector, we enter SLOW MODE because we need to @@ -701,9 +732,22 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { } /// Sends a DSR response for the current color scheme to the pty. +/// Record a Kitty clipboard protocol session grant so future requests +/// carrying the password skip the permission prompt. +pub fn kittyClipboardGrant( + self: *Termio, + pw: []const u8, + dir: terminalpkg.kitty.clipboard.Grants.Direction, +) error{OutOfMemory}!void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + try self.terminal_stream.handler.kittyClipboardGrant(pw, dir); +} + pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); try self.colorSchemeReportLocked(td, force); } @@ -723,6 +767,30 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi try self.queueWrite(td, writer.buffered(), false); } +/// Sends a visibility report to the pty. Unforced reports are only sent while +/// DEC mode 2033 is enabled. +pub fn visibilityReport( + self: *Termio, + td: *ThreadData, + visible: bool, + force: bool, +) !void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + if (!force and !self.renderer_state.terminal.modes.get(.report_visibility)) { + return; + } + + var buf: [terminalpkg.device_status.max_visibility_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.device_status.encodeVisibilityReport( + &writer, + if (visible) .potentially_visible else .not_visible, + ); + try self.queueWrite(td, writer.buffered(), false); +} + /// ThreadData is the data created and stored in the termio thread /// when the thread is started and destroyed when the thread is /// stopped. diff --git a/vendor/libghostty-vt/src/termio/Thread.zig b/vendor/libghostty-vt/src/termio/Thread.zig index ce4c1f4a..de268350 100644 --- a/vendor/libghostty-vt/src/termio/Thread.zig +++ b/vendor/libghostty-vt/src/termio/Thread.zig @@ -14,7 +14,8 @@ pub const Thread = @This(); const std = @import("std"); const ArenaAllocator = std.heap.ArenaAllocator; const builtin = @import("builtin"); -const xev = @import("../global.zig").xev; +const global = @import("../global.zig"); +const xev = global.xev; const crash = @import("../crash/main.zig"); const internal_os = @import("../os/main.zig"); const termio = @import("../termio.zig"); @@ -147,8 +148,8 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { // the error to the surface thread and let the apprt deal with it // in some way but this works for now. Without this, the user would // just see a blank terminal window. - io.renderer_state.mutex.lock(); - defer io.renderer_state.mutex.unlock(); + io.renderer_state.mutex.lockUncancelable(global.io()); + defer io.renderer_state.mutex.unlock(global.io()); const t = io.renderer_state.terminal; // Hide the cursor @@ -297,7 +298,7 @@ fn drainMailbox( // If we're draining, we just drain the mailbox and return. if (self.flags.drain) { - while (mailbox.pop()) |_| {} + while (mailbox.pop(global.io())) |msg| msg.deinit(); return; } @@ -305,13 +306,18 @@ fn drainMailbox( // expectation is that all our message handlers will be non-blocking // ENOUGH to not mess up throughput on producers. var redraw: bool = false; - while (mailbox.pop()) |message| { + while (mailbox.pop(global.io())) |message| { // If we have a message we always redraw redraw = true; log.debug("mailbox message={s}", .{@tagName(message)}); switch (message) { .color_scheme_report => |v| try io.colorSchemeReport(data, v.force), + .visibility_report => |v| try io.visibilityReport( + data, + v.visible, + v.force, + ), .crash => @panic("crash request, crashing intentionally"), .change_config => |config| { defer config.alloc.destroy(config.ptr); @@ -330,6 +336,14 @@ fn drainMailbox( } }, .jump_to_prompt => |v| try io.jumpToPrompt(v), + .kitty_clipboard_grant_read => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw, .read); + }, + .kitty_clipboard_grant_write => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw, .write); + }, .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, .focused => |v| try io.focusGained(data, v), diff --git a/vendor/libghostty-vt/src/termio/mailbox.zig b/vendor/libghostty-vt/src/termio/mailbox.zig index 2725d024..a053aaab 100644 --- a/vendor/libghostty-vt/src/termio/mailbox.zig +++ b/vendor/libghostty-vt/src/termio/mailbox.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const xev = @import("../global.zig").xev; +const global = @import("../global.zig"); +const xev = global.xev; const renderer = @import("../renderer.zig"); const termio = @import("../termio.zig"); const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; @@ -46,6 +47,7 @@ pub const Mailbox = union(enum) { pub fn deinit(self: *Mailbox, alloc: Allocator) void { switch (self.*) { .spsc => |*v| { + while (v.queue.pop(global.io())) |msg| msg.deinit(); v.queue.destroy(alloc); v.wakeup.deinit(); }, @@ -61,13 +63,13 @@ pub const Mailbox = union(enum) { pub fn send( self: *Mailbox, msg: termio.Message, - mutex: ?*std.Thread.Mutex, + mutex: ?*std.Io.Mutex, ) void { switch (self.*) { .spsc => |*mb| send: { // Try to write to the queue with an instant timeout. This is the // fast path because we can queue without a lock. - if (mb.queue.push(msg, .{ .instant = {} }) > 0) break :send; + if (mb.queue.push(global.io(), msg, .{ .instant = {} }) > 0) break :send; // If we enter this conditional, the queue is full. We wake up // the writer thread so that it can process messages to clear up @@ -75,6 +77,7 @@ pub const Mailbox = union(enum) { // lock so we need to unlock. mb.wakeup.notify() catch |err| { log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + msg.deinit(); return; }; @@ -87,9 +90,9 @@ pub const Mailbox = union(enum) { // are other messages in the writer queue (resize, focus) that // could acquire the lock. This is why we have to release our lock // here. - if (mutex) |m| m.unlock(); - defer if (mutex) |m| m.lock(); - _ = mb.queue.push(msg, .{ .forever = {} }); + if (mutex) |m| m.unlock(global.io()); + defer if (mutex) |m| m.lockUncancelable(global.io()); + if (mb.queue.push(global.io(), msg, .{ .forever = {} }) == 0) msg.deinit(); }, } } diff --git a/vendor/libghostty-vt/src/termio/message.zig b/vendor/libghostty-vt/src/termio/message.zig index 4ee7f245..24168740 100644 --- a/vendor/libghostty-vt/src/termio/message.zig +++ b/vendor/libghostty-vt/src/termio/message.zig @@ -8,20 +8,29 @@ const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// /// This is not a tiny structure (~40 bytes at the time of writing this comment), -/// but the messages are IO thread sends are also very few. At the current size -/// we can queue 26,000 messages before consuming a MB of RAM. +/// but the number of messages we send to the IO thread is also very small. +/// At the current size we can queue 26,000 messages before consuming a MB of RAM. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the largest /// other union value. It can be upped if we add a larger union member /// in the future. pub const WriteReq = MessageData(u8, 38); - /// Request a color scheme report is sent to the pty. + /// Request a color scheme report is sent to the PTY. color_scheme_report: struct { /// Force write the current color scheme force: bool, }, + /// Request a visibility report is sent to the PTY. + visibility_report: struct { + /// The visibility state to report. + visible: bool, + + /// Send the report even when mode 2033 is disabled. + force: bool, + }, + /// Purposely crash the renderer. This is used for testing and debugging. /// See the "crash" binding action. crash: void, @@ -39,7 +48,7 @@ pub const Message = union(enum) { /// Resize the window. resize: renderer.Size, - /// Request a size report is sent to the pty ([in-band + /// Request a size report is sent to the PTY ([in-band /// size report, mode 2048](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) and /// [XTWINOPS](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps;Ps;Ps-t.1EB0)). size_report: SizeReport, @@ -73,6 +82,12 @@ pub const Message = union(enum) { /// The surface gained or lost focus. focused: bool, + /// Record a Kitty clipboard protocol session grant for a password + /// so future requests carrying it skip the permission prompt, for + /// reads and writes respectively. + kitty_clipboard_grant_read: KittyClipboardGrant, + kitty_clipboard_grant_write: KittyClipboardGrant, + /// Write where the data fits in the union. write_small: WriteReq.Small, @@ -82,6 +97,13 @@ pub const Message = union(enum) { /// Write where the data is allocated and must be freed. write_alloc: WriteReq.Alloc, + /// The payload of the kitty_clipboard_grant_* messages. The + /// password is allocated and must be freed. + pub const KittyClipboardGrant = struct { + alloc: Allocator, + pw: []const u8, + }; + /// Return a write request for the given data. This will use /// write_small if it fits or write_alloc otherwise. This should NOT /// be used for stable pointers which can be manually set to write_stable. @@ -93,6 +115,22 @@ pub const Message = union(enum) { }; } + /// Free resources owned by a message that will not be processed. + /// The message is invalid after this call. + pub fn deinit(self: *const Message) void { + switch (self.*) { + .change_config => |v| { + v.ptr.deinit(); + v.alloc.destroy(v.ptr); + }, + .write_alloc => |v| v.alloc.free(v.data), + .kitty_clipboard_grant_read, + .kitty_clipboard_grant_write, + => |v| v.alloc.free(v.pw), + else => {}, + } + } + /// The types of size reports that we support. pub const SizeReport = terminal.size_report.Style; }; diff --git a/vendor/libghostty-vt/src/termio/shell_integration.zig b/vendor/libghostty-vt/src/termio/shell_integration.zig index 2dd09ee2..d56a84cc 100644 --- a/vendor/libghostty-vt/src/termio/shell_integration.zig +++ b/vendor/libghostty-vt/src/termio/shell_integration.zig @@ -2,10 +2,11 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const EnvMap = std.process.EnvMap; +const EnvMap = std.process.Environ.Map; const config = @import("../config.zig"); const homedir = @import("../os/homedir.zig"); const internal_os = @import("../os/main.zig"); +const global = @import("../global.zig"); const log = std.log.scoped(.shell_integration); @@ -97,7 +98,7 @@ test "force shell" { inline for (@typeInfo(Shell).@"enum".fields) |field| { const shell = @field(Shell, field.name); - var res: TmpResourcesDir = try .init(alloc, shell); + var res: TmpResourcesDir = try .init(shell); defer res.deinit(); const result = try setup( @@ -373,12 +374,12 @@ fn setupBash( "{s}/shell-integration/bash/ghostty.bash", .{resource_dir}, ); - if (std.fs.openFileAbsolute(script_path, .{})) |file| { - file.close(); + if (std.Io.Dir.openFileAbsolute(global.io(), script_path, .{})) |file| { + file.close(global.io()); try env.put("ENV", script_path); } else |err| { log.warn("unable to open {s}: {}", .{ script_path, err }); - env.remove("GHOSTTY_BASH_ENV"); + _ = env.swapRemove("GHOSTTY_BASH_ENV"); return null; } @@ -390,8 +391,10 @@ fn setupBash( // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're // staying in POSIX mode (--posix), change it back to ~/.bash_history. if (env.get("HISTFILE") == null) { + var environ_map = try global.environMap(); + defer environ_map.deinit(); var home_buf: [1024]u8 = undefined; - if (try homedir.home(&home_buf)) |home| { + if (try homedir.home(global.io(), &environ_map, &home_buf)) |home| { var histfile_buf: [std.fs.max_path_bytes]u8 = undefined; const histfile = try std.fmt.bufPrint( &histfile_buf, @@ -404,7 +407,7 @@ fn setupBash( } // Return a copy of our modified command line to use as the shell command. - return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; + return .{ .shell = try alloc.dupeZ(u8, cmd.buffer.written()) }; } test "bash" { @@ -413,7 +416,7 @@ test "bash" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); var env = EnvMap.init(alloc); @@ -436,7 +439,7 @@ test "bash: unsupported options" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); const cmdlines = [_][:0]const u8{ @@ -462,7 +465,7 @@ test "bash: inject flags" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); // bash --norc @@ -492,7 +495,7 @@ test "bash: rcfile" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); var env = EnvMap.init(alloc); @@ -519,7 +522,7 @@ test "bash: HISTFILE" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); // HISTFILE unset @@ -551,7 +554,7 @@ test "bash: ENV" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); var env = EnvMap.init(alloc); @@ -575,7 +578,7 @@ test "bash: additional arguments" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .bash); + var res: TmpResourcesDir = try .init(.bash); defer res.deinit(); var env = EnvMap.init(alloc); @@ -603,7 +606,7 @@ test "bash: missing resources" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc); defer alloc.free(resources_dir); var env = EnvMap.init(alloc); @@ -633,11 +636,15 @@ fn setupXdgDataDirs( "{s}/shell-integration", .{resource_dir}, ); - var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + var integ_dir = std.Io.Dir.openDirAbsolute( + global.io(), + integ_path, + .{}, + ) catch |err| { log.warn("unable to open {s}: {}", .{ integ_path, err }); return false; }; - integ_dir.close(); + integ_dir.close(global.io()); // Set an env var so we can remove this from XDG_DATA_DIRS later. // This happens in the shell integration config itself. We do this @@ -659,7 +666,7 @@ fn setupXdgDataDirs( const xdg_data_dirs_key = "XDG_DATA_DIRS"; try env.put( xdg_data_dirs_key, - try internal_os.prependEnv( + try prependEnv( stack_alloc, env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share", integ_path, @@ -669,6 +676,23 @@ fn setupXdgDataDirs( return true; } +/// Prepend a value to an environment variable such as PATH. +/// The returned value is always allocated so it must be freed. +fn prependEnv( + alloc: Allocator, + current: []const u8, + value: []const u8, +) Allocator.Error![]u8 { + // If there is no prior value, we return it as-is + if (current.len == 0) return try alloc.dupe(u8, value); + + return try std.fmt.allocPrint(alloc, "{s}{c}{s}", .{ + value, + std.fs.path.delimiter, + current, + }); +} + test "xdg: empty XDG_DATA_DIRS" { if (builtin.os.tag == .windows) return error.SkipZigTest; @@ -678,7 +702,7 @@ test "xdg: empty XDG_DATA_DIRS" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .fish); + var res: TmpResourcesDir = try .init(.fish); defer res.deinit(); var env = EnvMap.init(alloc); @@ -706,7 +730,7 @@ test "xdg: existing XDG_DATA_DIRS" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .fish); + var res: TmpResourcesDir = try .init(.fish); defer res.deinit(); var env = EnvMap.init(alloc); @@ -736,7 +760,7 @@ test "xdg: missing resources" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc); defer alloc.free(resources_dir); var env = EnvMap.init(alloc); @@ -815,7 +839,7 @@ fn setupNushell( } // Return a copy of our modified command line to use as the shell command. - return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; + return .{ .shell = try alloc.dupeZ(u8, cmd.buffer.written()) }; } test "nushell" { @@ -824,7 +848,7 @@ test "nushell" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .nushell); + var res: TmpResourcesDir = try .init(.nushell); defer res.deinit(); var env = EnvMap.init(alloc); @@ -850,7 +874,7 @@ test "nushell: unsupported options" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(alloc, .nushell); + var res: TmpResourcesDir = try .init(.nushell); defer res.deinit(); const cmdlines = [_][:0]const u8{ @@ -879,7 +903,7 @@ test "nushell: missing resources" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc); defer alloc.free(resources_dir); var env = EnvMap.init(alloc); @@ -910,11 +934,15 @@ fn setupZsh( "{s}/shell-integration/zsh", .{resource_dir}, ); - var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + var integ_dir = std.Io.Dir.openDirAbsolute( + global.io(), + integ_path, + .{}, + ) catch |err| { log.warn("unable to open {s}: {}", .{ integ_path, err }); return null; }; - integ_dir.close(); + integ_dir.close(global.io()); try env.put("ZDOTDIR", integ_path); return try command.clone(alloc); @@ -927,7 +955,7 @@ test "zsh" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + var res: TmpResourcesDir = try .init(.zsh); defer res.deinit(); var env = EnvMap.init(testing.allocator); @@ -946,7 +974,7 @@ test "zsh: ZDOTDIR" { defer arena.deinit(); const alloc = arena.allocator(); - var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + var res: TmpResourcesDir = try .init(.zsh); defer res.deinit(); var env = EnvMap.init(testing.allocator); @@ -969,7 +997,7 @@ test "zsh: missing resources" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + const resources_dir = try tmp_dir.dir.realPathFileAlloc(testing.io, ".", alloc); defer alloc.free(resources_dir); var env = EnvMap.init(alloc); @@ -981,12 +1009,11 @@ test "zsh: missing resources" { /// Test helper that creates a temporary resources directory with shell integration paths. const TmpResourcesDir = struct { - allocator: Allocator, tmp_dir: std.testing.TmpDir, - path: []const u8, + path: [:0]const u8, shell_path: []const u8, - fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir { + fn init(shell: Shell) !TmpResourcesDir { var tmp_dir = std.testing.tmpDir(.{}); errdefer tmp_dir.cleanup(); @@ -996,20 +1023,20 @@ const TmpResourcesDir = struct { "shell-integration/{s}", .{@tagName(shell)}, ); - try tmp_dir.dir.makePath(relative_shell_path); + try tmp_dir.dir.createDirPath(std.testing.io, relative_shell_path); - const path = try tmp_dir.dir.realpathAlloc(allocator, "."); - errdefer allocator.free(path); + const path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator); + errdefer std.testing.allocator.free(path); const shell_path = try std.fmt.allocPrint( - allocator, + std.testing.allocator, "{s}/{s}", .{ path, relative_shell_path }, ); - errdefer allocator.free(shell_path); + errdefer std.testing.allocator.free(shell_path); switch (shell) { - .bash => try tmp_dir.dir.writeFile(.{ + .bash => try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "shell-integration/bash/ghostty.bash", .data = "", }), @@ -1017,7 +1044,6 @@ const TmpResourcesDir = struct { } return .{ - .allocator = allocator, .tmp_dir = tmp_dir, .path = path, .shell_path = shell_path, @@ -1025,8 +1051,8 @@ const TmpResourcesDir = struct { } fn deinit(self: *TmpResourcesDir) void { - self.allocator.free(self.shell_path); - self.allocator.free(self.path); + std.testing.allocator.free(self.shell_path); + std.testing.allocator.free(self.path); self.tmp_dir.cleanup(); } }; 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); +} |
