Commit 0222428
Eric Bower
·
2026-03-04 21:55:55 -0500 EST
parent fe2c4ce
refactor: break up main.zig This is just some code cleanup to make it a little easier to navigate the codebase. I don't want to break everything up into separate files but I want the main business logic to live inside of main.zig.
5 files changed,
+976,
-966
+30,
-0
| ... | ... | @@ -0,0 +1,30 @@ | |
| 1 | + | const builtin = @import("builtin"); | |
| 2 | + | ||
| 3 | + | pub const c = switch (builtin.os.tag) { | |
| 4 | + | .macos => @cImport({ | |
| 5 | + | @cInclude("sys/ioctl.h"); // ioctl and constants | |
| 6 | + | @cInclude("termios.h"); | |
| 7 | + | @cInclude("stdlib.h"); | |
| 8 | + | @cInclude("unistd.h"); | |
| 9 | + | }), | |
| 10 | + | .freebsd => @cImport({ | |
| 11 | + | @cInclude("termios.h"); // ioctl and constants | |
| 12 | + | @cInclude("libutil.h"); // openpty() | |
| 13 | + | @cInclude("stdlib.h"); | |
| 14 | + | @cInclude("unistd.h"); | |
| 15 | + | }), | |
| 16 | + | else => @cImport({ | |
| 17 | + | @cInclude("sys/ioctl.h"); // ioctl and constants | |
| 18 | + | @cInclude("pty.h"); | |
| 19 | + | @cInclude("stdlib.h"); | |
| 20 | + | @cInclude("unistd.h"); | |
| 21 | + | }), | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | // Manually declare forkpty for macOS since util.h is not available during cross-compilation | |
| 25 | + | pub const forkpty = if (builtin.os.tag == .macos) | |
| 26 | + | struct { | |
| 27 | + | extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int; | |
| 28 | + | }.forkpty | |
| 29 | + | else | |
| 30 | + | c.forkpty; |
+56,
-0
| ... | ... | @@ -1,5 +1,7 @@ | |
| 1 | 1 | const std = @import("std"); | |
| 2 | 2 | const posix = std.posix; | |
| 3 | + | const cross = @import("cross.zig"); | |
| 4 | + | const socket = @import("socket.zig"); | |
| 3 | 5 | ||
| 4 | 6 | pub const Tag = enum(u8) { | |
| 5 | 7 | Input = 0, |
| ... | ... | @@ -25,6 +27,14 @@ pub const Resize = packed struct { | |
| 25 | 27 | cols: u16, | |
| 26 | 28 | }; | |
| 27 | 29 | ||
| 30 | + | pub fn getTerminalSize(fd: i32) Resize { | |
| 31 | + | var ws: cross.c.struct_winsize = undefined; | |
| 32 | + | if (cross.c.ioctl(fd, cross.c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) { | |
| 33 | + | return .{ .rows = ws.ws_row, .cols = ws.ws_col }; | |
| 34 | + | } | |
| 35 | + | return .{ .rows = 24, .cols = 80 }; | |
| 36 | + | } | |
| 37 | + | ||
| 28 | 38 | pub const MAX_CMD_LEN = 256; | |
| 29 | 39 | pub const MAX_CWD_LEN = 256; | |
| 30 | 40 |
| ... | ... | @@ -150,3 +160,49 @@ pub const SocketBuffer = struct { | |
| 150 | 160 | return .{ .header = hdr, .payload = pay }; | |
| 151 | 161 | } | |
| 152 | 162 | }; | |
| 163 | + | ||
| 164 | + | const SessionProbeError = error{ | |
| 165 | + | Timeout, | |
| 166 | + | ConnectionRefused, | |
| 167 | + | Unexpected, | |
| 168 | + | }; | |
| 169 | + | ||
| 170 | + | const SessionProbeResult = struct { | |
| 171 | + | fd: i32, | |
| 172 | + | info: Info, | |
| 173 | + | }; | |
| 174 | + | ||
| 175 | + | pub fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult { | |
| 176 | + | const timeout_ms = 1000; | |
| 177 | + | const fd = socket.sessionConnect(socket_path) catch |err| switch (err) { | |
| 178 | + | error.ConnectionRefused => return error.ConnectionRefused, | |
| 179 | + | else => return error.Unexpected, | |
| 180 | + | }; | |
| 181 | + | errdefer posix.close(fd); | |
| 182 | + | ||
| 183 | + | send(fd, .Info, "") catch return error.Unexpected; | |
| 184 | + | ||
| 185 | + | var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; | |
| 186 | + | const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected; | |
| 187 | + | if (poll_result == 0) { | |
| 188 | + | return error.Timeout; | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | var sb = SocketBuffer.init(alloc) catch return error.Unexpected; | |
| 192 | + | defer sb.deinit(); | |
| 193 | + | ||
| 194 | + | const n = sb.read(fd) catch return error.Unexpected; | |
| 195 | + | if (n == 0) return error.Unexpected; | |
| 196 | + | ||
| 197 | + | while (sb.next()) |msg| { | |
| 198 | + | if (msg.header.tag == .Info) { | |
| 199 | + | if (msg.payload.len == @sizeOf(Info)) { | |
| 200 | + | return .{ | |
| 201 | + | .fd = fd, | |
| 202 | + | .info = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]), | |
| 203 | + | }; | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | return error.Unexpected; | |
| 208 | + | } |
+317,
-966
| ... | ... | @@ -6,6 +6,9 @@ const ghostty_vt = @import("ghostty-vt"); | |
| 6 | 6 | const ipc = @import("ipc.zig"); | |
| 7 | 7 | const log = @import("log.zig"); | |
| 8 | 8 | const completions = @import("completions.zig"); | |
| 9 | + | const util = @import("util.zig"); | |
| 10 | + | const cross = @import("cross.zig"); | |
| 11 | + | const socket = @import("socket.zig"); | |
| 9 | 12 | ||
| 10 | 13 | pub const version = build_options.version; | |
| 11 | 14 | pub const git_sha = build_options.git_sha; |
| ... | ... | @@ -27,38 +30,147 @@ fn zmxLogFn( | |
| 27 | 30 | log_system.log(level, scope, format, args); | |
| 28 | 31 | } | |
| 29 | 32 | ||
| 30 | - | const c = switch (builtin.os.tag) { | |
| 31 | - | .macos => @cImport({ | |
| 32 | - | @cInclude("sys/ioctl.h"); // ioctl and constants | |
| 33 | - | @cInclude("termios.h"); | |
| 34 | - | @cInclude("stdlib.h"); | |
| 35 | - | @cInclude("unistd.h"); | |
| 36 | - | }), | |
| 37 | - | .freebsd => @cImport({ | |
| 38 | - | @cInclude("termios.h"); // ioctl and constants | |
| 39 | - | @cInclude("libutil.h"); // openpty() | |
| 40 | - | @cInclude("stdlib.h"); | |
| 41 | - | @cInclude("unistd.h"); | |
| 42 | - | }), | |
| 43 | - | else => @cImport({ | |
| 44 | - | @cInclude("sys/ioctl.h"); // ioctl and constants | |
| 45 | - | @cInclude("pty.h"); | |
| 46 | - | @cInclude("stdlib.h"); | |
| 47 | - | @cInclude("unistd.h"); | |
| 48 | - | }), | |
| 49 | - | }; | |
| 50 | - | ||
| 51 | - | // Manually declare forkpty for macOS since util.h is not available during cross-compilation | |
| 52 | - | const forkpty = if (builtin.os.tag == .macos) | |
| 53 | - | struct { | |
| 54 | - | extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int; | |
| 55 | - | }.forkpty | |
| 56 | - | else | |
| 57 | - | c.forkpty; | |
| 58 | - | ||
| 59 | 33 | var sigwinch_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); | |
| 60 | 34 | var sigterm_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); | |
| 61 | 35 | ||
| 36 | + | pub fn main() !void { | |
| 37 | + | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking | |
| 38 | + | const alloc = std.heap.c_allocator; | |
| 39 | + | ||
| 40 | + | var args = try std.process.argsWithAllocator(alloc); | |
| 41 | + | defer args.deinit(); | |
| 42 | + | _ = args.skip(); // skip program name | |
| 43 | + | ||
| 44 | + | var cfg = try Cfg.init(alloc); | |
| 45 | + | defer cfg.deinit(alloc); | |
| 46 | + | ||
| 47 | + | const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" }); | |
| 48 | + | defer alloc.free(log_path); | |
| 49 | + | try log_system.init(alloc, log_path); | |
| 50 | + | defer log_system.deinit(); | |
| 51 | + | ||
| 52 | + | const cmd = args.next() orelse { | |
| 53 | + | return list(&cfg, false); | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) { | |
| 57 | + | return printVersion(&cfg); | |
| 58 | + | } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) { | |
| 59 | + | return help(); | |
| 60 | + | } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) { | |
| 61 | + | const short = if (args.next()) |arg| std.mem.eql(u8, arg, "--short") else false; | |
| 62 | + | return list(&cfg, short); | |
| 63 | + | } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) { | |
| 64 | + | const arg = args.next() orelse return; | |
| 65 | + | const shell = completions.Shell.fromString(arg) orelse return; | |
| 66 | + | return printCompletions(shell); | |
| 67 | + | } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) { | |
| 68 | + | return detachAll(&cfg); | |
| 69 | + | } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) { | |
| 70 | + | const session_name = args.next() orelse ""; | |
| 71 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 72 | + | defer alloc.free(sesh); | |
| 73 | + | return kill(&cfg, sesh); | |
| 74 | + | } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) { | |
| 75 | + | var session_name: ?[]const u8 = null; | |
| 76 | + | var format: util.HistoryFormat = .plain; | |
| 77 | + | while (args.next()) |arg| { | |
| 78 | + | if (std.mem.eql(u8, arg, "--vt")) { | |
| 79 | + | format = .vt; | |
| 80 | + | } else if (std.mem.eql(u8, arg, "--html")) { | |
| 81 | + | format = .html; | |
| 82 | + | } else if (session_name == null) { | |
| 83 | + | session_name = arg; | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | const sesh = try socket.getSeshName(alloc, session_name.?); | |
| 87 | + | defer alloc.free(sesh); | |
| 88 | + | return history(&cfg, sesh, format); | |
| 89 | + | } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) { | |
| 90 | + | const session_name = args.next() orelse ""; | |
| 91 | + | ||
| 92 | + | var command_args: std.ArrayList([]const u8) = .empty; | |
| 93 | + | defer command_args.deinit(alloc); | |
| 94 | + | while (args.next()) |arg| { | |
| 95 | + | try command_args.append(alloc, arg); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); | |
| 99 | + | var command: ?[][]const u8 = null; | |
| 100 | + | if (command_args.items.len > 0) { | |
| 101 | + | command = command_args.items; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 105 | + | const cwd = std.posix.getcwd(&cwd_buf) catch ""; | |
| 106 | + | ||
| 107 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 108 | + | defer alloc.free(sesh); | |
| 109 | + | var daemon = Daemon{ | |
| 110 | + | .running = true, | |
| 111 | + | .cfg = &cfg, | |
| 112 | + | .alloc = alloc, | |
| 113 | + | .clients = clients, | |
| 114 | + | .session_name = sesh, | |
| 115 | + | .socket_path = undefined, | |
| 116 | + | .pid = undefined, | |
| 117 | + | .command = command, | |
| 118 | + | .cwd = cwd, | |
| 119 | + | .created_at = @intCast(std.time.timestamp()), | |
| 120 | + | }; | |
| 121 | + | daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh); | |
| 122 | + | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 123 | + | return attach(&daemon); | |
| 124 | + | } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) { | |
| 125 | + | const session_name = args.next() orelse ""; | |
| 126 | + | ||
| 127 | + | var cmd_args_raw: std.ArrayList([]const u8) = .empty; | |
| 128 | + | defer cmd_args_raw.deinit(alloc); | |
| 129 | + | while (args.next()) |arg| { | |
| 130 | + | try cmd_args_raw.append(alloc, arg); | |
| 131 | + | } | |
| 132 | + | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); | |
| 133 | + | ||
| 134 | + | var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 135 | + | const cwd = std.posix.getcwd(&cwd_buf) catch ""; | |
| 136 | + | ||
| 137 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 138 | + | defer alloc.free(sesh); | |
| 139 | + | var daemon = Daemon{ | |
| 140 | + | .running = true, | |
| 141 | + | .cfg = &cfg, | |
| 142 | + | .alloc = alloc, | |
| 143 | + | .clients = clients, | |
| 144 | + | .session_name = sesh, | |
| 145 | + | .socket_path = undefined, | |
| 146 | + | .pid = undefined, | |
| 147 | + | .command = null, | |
| 148 | + | .cwd = cwd, | |
| 149 | + | .created_at = @intCast(std.time.timestamp()), | |
| 150 | + | .is_task_mode = true, | |
| 151 | + | .task_command = cmd_args_raw.items, | |
| 152 | + | }; | |
| 153 | + | daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh); | |
| 154 | + | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 155 | + | return run(&daemon, cmd_args_raw.items); | |
| 156 | + | } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) { | |
| 157 | + | var args_raw: std.ArrayList([]const u8) = .empty; | |
| 158 | + | defer { | |
| 159 | + | for (args_raw.items) |sesh| { | |
| 160 | + | alloc.free(sesh); | |
| 161 | + | } | |
| 162 | + | args_raw.deinit(alloc); | |
| 163 | + | } | |
| 164 | + | while (args.next()) |session_name| { | |
| 165 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 166 | + | try args_raw.append(alloc, sesh); | |
| 167 | + | } | |
| 168 | + | return wait(&cfg, args_raw); | |
| 169 | + | } else { | |
| 170 | + | return help(); | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 62 | 174 | const Client = struct { | |
| 63 | 175 | alloc: std.mem.Allocator, | |
| 64 | 176 | socket_fd: i32, |
| ... | ... | @@ -121,11 +233,9 @@ const Cfg = struct { | |
| 121 | 233 | } | |
| 122 | 234 | }; | |
| 123 | 235 | ||
| 124 | - | const SessionMetadata = struct { | |
| 125 | - | created_at: u64, // unix timestamp (ns) - all sessions | |
| 126 | - | task_exit_code: ?i32 = null, // null = running, set when task completes | |
| 127 | - | task_end_time: ?u64 = null, // timestamp when task exited | |
| 128 | - | task_command: []const u8 = "", // original task command string | |
| 236 | + | const EnsureSessionResult = struct { | |
| 237 | + | created: bool, | |
| 238 | + | is_daemon: bool, | |
| 129 | 239 | }; | |
| 130 | 240 | ||
| 131 | 241 | const Daemon = struct { |
| ... | ... | @@ -175,6 +285,120 @@ const Daemon = struct { | |
| 175 | 285 | return false; | |
| 176 | 286 | } | |
| 177 | 287 | ||
| 288 | + | fn spawnPty(self: *Daemon) !c_int { | |
| 289 | + | const size = ipc.getTerminalSize(posix.STDOUT_FILENO); | |
| 290 | + | var ws: cross.c.struct_winsize = .{ | |
| 291 | + | .ws_row = size.rows, | |
| 292 | + | .ws_col = size.cols, | |
| 293 | + | .ws_xpixel = 0, | |
| 294 | + | .ws_ypixel = 0, | |
| 295 | + | }; | |
| 296 | + | ||
| 297 | + | var master_fd: c_int = undefined; | |
| 298 | + | const pid = cross.forkpty(&master_fd, null, null, &ws); | |
| 299 | + | if (pid < 0) { | |
| 300 | + | return error.ForkPtyFailed; | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | if (pid == 0) { // child pid code path | |
| 304 | + | const session_env = try std.fmt.allocPrint(self.alloc, "ZMX_SESSION={s}\x00", .{self.session_name}); | |
| 305 | + | _ = cross.c.putenv(@ptrCast(session_env.ptr)); | |
| 306 | + | ||
| 307 | + | if (self.command) |cmd_args| { | |
| 308 | + | const alloc = std.heap.c_allocator; | |
| 309 | + | var argv_buf: [64:null]?[*:0]const u8 = undefined; | |
| 310 | + | for (cmd_args, 0..) |arg, i| { | |
| 311 | + | argv_buf[i] = alloc.dupeZ(u8, arg) catch { | |
| 312 | + | std.posix.exit(1); | |
| 313 | + | }; | |
| 314 | + | } | |
| 315 | + | argv_buf[cmd_args.len] = null; | |
| 316 | + | const argv: [*:null]const ?[*:0]const u8 = &argv_buf; | |
| 317 | + | const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ); | |
| 318 | + | std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) }); | |
| 319 | + | std.posix.exit(1); | |
| 320 | + | } else { | |
| 321 | + | const shell = util.detectShell(); | |
| 322 | + | // Use "-shellname" as argv[0] to signal login shell (traditional method) | |
| 323 | + | var buf: [64]u8 = undefined; | |
| 324 | + | const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)}); | |
| 325 | + | const argv = [_:null]?[*:0]const u8{ login_shell, null }; | |
| 326 | + | const err = std.posix.execveZ(shell, &argv, std.c.environ); | |
| 327 | + | std.log.err("execve failed: err={s}", .{@errorName(err)}); | |
| 328 | + | std.posix.exit(1); | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | // master pid code path | |
| 332 | + | self.pid = pid; | |
| 333 | + | std.log.info("pty spawned session={s} pid={d}", .{ self.session_name, pid }); | |
| 334 | + | ||
| 335 | + | // make pty non-blocking | |
| 336 | + | const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0); | |
| 337 | + | _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000)); | |
| 338 | + | return master_fd; | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | fn ensureSession(self: *Daemon) !EnsureSessionResult { | |
| 342 | + | var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{}); | |
| 343 | + | defer dir.close(); | |
| 344 | + | ||
| 345 | + | const exists = try socket.sessionExists(dir, self.session_name); | |
| 346 | + | var should_create = !exists; | |
| 347 | + | ||
| 348 | + | if (exists) { | |
| 349 | + | if (ipc.probeSession(self.alloc, self.socket_path)) |result| { | |
| 350 | + | posix.close(result.fd); | |
| 351 | + | if (self.command != null) { | |
| 352 | + | std.log.warn("session already exists, ignoring command session={s}", .{self.session_name}); | |
| 353 | + | } | |
| 354 | + | } else |_| { | |
| 355 | + | socket.cleanupStaleSocket(dir, self.session_name); | |
| 356 | + | should_create = true; | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | if (should_create) { | |
| 361 | + | std.log.info("creating session={s}", .{self.session_name}); | |
| 362 | + | const server_sock_fd = try socket.createSocket(self.socket_path); | |
| 363 | + | ||
| 364 | + | const pid = try posix.fork(); | |
| 365 | + | if (pid == 0) { // child (daemon) | |
| 366 | + | _ = try posix.setsid(); | |
| 367 | + | ||
| 368 | + | log_system.deinit(); | |
| 369 | + | const session_log_name = try std.fmt.allocPrint(self.alloc, "{s}.log", .{self.session_name}); | |
| 370 | + | defer self.alloc.free(session_log_name); | |
| 371 | + | const session_log_path = try std.fs.path.join(self.alloc, &.{ self.cfg.log_dir, session_log_name }); | |
| 372 | + | defer self.alloc.free(session_log_path); | |
| 373 | + | try log_system.init(self.alloc, session_log_path); | |
| 374 | + | ||
| 375 | + | errdefer { | |
| 376 | + | posix.close(server_sock_fd); | |
| 377 | + | dir.deleteFile(self.session_name) catch {}; | |
| 378 | + | } | |
| 379 | + | const pty_fd = try self.spawnPty(); | |
| 380 | + | defer { | |
| 381 | + | posix.close(pty_fd); | |
| 382 | + | posix.close(server_sock_fd); | |
| 383 | + | std.log.info("deleting socket file session_name={s}", .{self.session_name}); | |
| 384 | + | dir.deleteFile(self.session_name) catch |err| { | |
| 385 | + | std.log.warn("failed to delete socket file err={s}", .{@errorName(err)}); | |
| 386 | + | }; | |
| 387 | + | } | |
| 388 | + | try daemonLoop(self, server_sock_fd, pty_fd); | |
| 389 | + | self.handleKill(); | |
| 390 | + | _ = posix.waitpid(self.pid, 0); | |
| 391 | + | self.deinit(); | |
| 392 | + | return .{ .created = true, .is_daemon = true }; | |
| 393 | + | } | |
| 394 | + | posix.close(server_sock_fd); | |
| 395 | + | std.Thread.sleep(10 * std.time.ns_per_ms); | |
| 396 | + | return .{ .created = true, .is_daemon = false }; | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | return .{ .created = false, .is_daemon = false }; | |
| 400 | + | } | |
| 401 | + | ||
| 178 | 402 | pub fn handleInput(self: *Daemon, pty_fd: i32, payload: []const u8) !void { | |
| 179 | 403 | _ = self; | |
| 180 | 404 | if (payload.len > 0) { |
| ... | ... | @@ -193,13 +417,13 @@ const Daemon = struct { | |
| 193 | 417 | ||
| 194 | 418 | const resize = std.mem.bytesToValue(ipc.Resize, payload); | |
| 195 | 419 | ||
| 196 | - | var ws: c.struct_winsize = .{ | |
| 420 | + | var ws: cross.c.struct_winsize = .{ | |
| 197 | 421 | .ws_row = resize.rows, | |
| 198 | 422 | .ws_col = resize.cols, | |
| 199 | 423 | .ws_xpixel = 0, | |
| 200 | 424 | .ws_ypixel = 0, | |
| 201 | 425 | }; | |
| 202 | - | _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws); | |
| 426 | + | _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws); | |
| 203 | 427 | try term.resize(self.alloc, resize.cols, resize.rows); | |
| 204 | 428 | ||
| 205 | 429 | // Serialize terminal state BEFORE resize to capture correct cursor position. |
| ... | ... | @@ -210,7 +434,7 @@ const Daemon = struct { | |
| 210 | 434 | if (self.has_pty_output and self.has_had_client) { | |
| 211 | 435 | const cursor = &term.screens.active.cursor; | |
| 212 | 436 | std.log.debug("cursor before serialize: x={d} y={d} pending_wrap={}", .{ cursor.x, cursor.y, cursor.pending_wrap }); | |
| 213 | - | if (serializeTerminalState(self.alloc, term)) |term_output| { | |
| 437 | + | if (util.serializeTerminalState(self.alloc, term)) |term_output| { | |
| 214 | 438 | std.log.debug("serialize terminal state", .{}); | |
| 215 | 439 | defer self.alloc.free(term_output); | |
| 216 | 440 | ipc.appendMessage(self.alloc, &client.write_buf, .Output, term_output) catch |err| { |
| ... | ... | @@ -230,13 +454,13 @@ const Daemon = struct { | |
| 230 | 454 | if (payload.len != @sizeOf(ipc.Resize)) return; | |
| 231 | 455 | ||
| 232 | 456 | const resize = std.mem.bytesToValue(ipc.Resize, payload); | |
| 233 | - | var ws: c.struct_winsize = .{ | |
| 457 | + | var ws: cross.c.struct_winsize = .{ | |
| 234 | 458 | .ws_row = resize.rows, | |
| 235 | 459 | .ws_col = resize.cols, | |
| 236 | 460 | .ws_xpixel = 0, | |
| 237 | 461 | .ws_ypixel = 0, | |
| 238 | 462 | }; | |
| 239 | - | _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws); | |
| 463 | + | _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws); | |
| 240 | 464 | try term.resize(self.alloc, resize.cols, resize.rows); | |
| 241 | 465 | std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols }); | |
| 242 | 466 | } |
| ... | ... | @@ -281,8 +505,8 @@ const Daemon = struct { | |
| 281 | 505 | const cur_cmd = self.command orelse self.task_command; | |
| 282 | 506 | if (cur_cmd) |args| { | |
| 283 | 507 | for (args, 0..) |arg, i| { | |
| 284 | - | const quoted = if (shellNeedsQuoting(arg)) | |
| 285 | - | shellQuote(self.alloc, arg) catch null | |
| 508 | + | const quoted = if (util.shellNeedsQuoting(arg)) | |
| 509 | + | util.shellQuote(self.alloc, arg) catch null | |
| 286 | 510 | else | |
| 287 | 511 | null; | |
| 288 | 512 | defer if (quoted) |q| self.alloc.free(q); |
| ... | ... | @@ -328,11 +552,11 @@ const Daemon = struct { | |
| 328 | 552 | } | |
| 329 | 553 | ||
| 330 | 554 | pub fn handleHistory(self: *Daemon, client: *Client, term: *ghostty_vt.Terminal, payload: []const u8) !void { | |
| 331 | - | const format: HistoryFormat = if (payload.len > 0) | |
| 555 | + | const format: util.HistoryFormat = if (payload.len > 0) | |
| 332 | 556 | @enumFromInt(payload[0]) | |
| 333 | 557 | else | |
| 334 | 558 | .plain; | |
| 335 | - | if (serializeTerminal(self.alloc, term, format)) |output| { | |
| 559 | + | if (util.serializeTerminal(self.alloc, term, format)) |output| { | |
| 336 | 560 | defer self.alloc.free(output); | |
| 337 | 561 | try ipc.appendMessage(self.alloc, &client.write_buf, .History, output); | |
| 338 | 562 | client.has_pending_output = true; |
| ... | ... | @@ -353,144 +577,6 @@ const Daemon = struct { | |
| 353 | 577 | } | |
| 354 | 578 | }; | |
| 355 | 579 | ||
| 356 | - | pub fn main() !void { | |
| 357 | - | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking | |
| 358 | - | const alloc = std.heap.c_allocator; | |
| 359 | - | ||
| 360 | - | var args = try std.process.argsWithAllocator(alloc); | |
| 361 | - | defer args.deinit(); | |
| 362 | - | _ = args.skip(); // skip program name | |
| 363 | - | ||
| 364 | - | var cfg = try Cfg.init(alloc); | |
| 365 | - | defer cfg.deinit(alloc); | |
| 366 | - | ||
| 367 | - | const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" }); | |
| 368 | - | defer alloc.free(log_path); | |
| 369 | - | try log_system.init(alloc, log_path); | |
| 370 | - | defer log_system.deinit(); | |
| 371 | - | ||
| 372 | - | const cmd = args.next() orelse { | |
| 373 | - | return list(&cfg, false); | |
| 374 | - | }; | |
| 375 | - | ||
| 376 | - | if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) { | |
| 377 | - | return printVersion(&cfg); | |
| 378 | - | } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) { | |
| 379 | - | return help(); | |
| 380 | - | } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) { | |
| 381 | - | const short = if (args.next()) |arg| std.mem.eql(u8, arg, "--short") else false; | |
| 382 | - | return list(&cfg, short); | |
| 383 | - | } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) { | |
| 384 | - | const arg = args.next() orelse return; | |
| 385 | - | const shell = completions.Shell.fromString(arg) orelse return; | |
| 386 | - | return printCompletions(shell); | |
| 387 | - | } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) { | |
| 388 | - | return detachAll(&cfg); | |
| 389 | - | } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) { | |
| 390 | - | const session_name = args.next() orelse ""; | |
| 391 | - | const sesh = try getSeshName(alloc, session_name); | |
| 392 | - | defer alloc.free(sesh); | |
| 393 | - | return kill(&cfg, sesh); | |
| 394 | - | } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) { | |
| 395 | - | var session_name: ?[]const u8 = null; | |
| 396 | - | var format: HistoryFormat = .plain; | |
| 397 | - | while (args.next()) |arg| { | |
| 398 | - | if (std.mem.eql(u8, arg, "--vt")) { | |
| 399 | - | format = .vt; | |
| 400 | - | } else if (std.mem.eql(u8, arg, "--html")) { | |
| 401 | - | format = .html; | |
| 402 | - | } else if (session_name == null) { | |
| 403 | - | session_name = arg; | |
| 404 | - | } | |
| 405 | - | } | |
| 406 | - | const sesh = try getSeshName(alloc, session_name.?); | |
| 407 | - | defer alloc.free(sesh); | |
| 408 | - | return history(&cfg, sesh, format); | |
| 409 | - | } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) { | |
| 410 | - | const session_name = args.next() orelse ""; | |
| 411 | - | ||
| 412 | - | var command_args: std.ArrayList([]const u8) = .empty; | |
| 413 | - | defer command_args.deinit(alloc); | |
| 414 | - | while (args.next()) |arg| { | |
| 415 | - | try command_args.append(alloc, arg); | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); | |
| 419 | - | var command: ?[][]const u8 = null; | |
| 420 | - | if (command_args.items.len > 0) { | |
| 421 | - | command = command_args.items; | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 425 | - | const cwd = std.posix.getcwd(&cwd_buf) catch ""; | |
| 426 | - | ||
| 427 | - | const sesh = try getSeshName(alloc, session_name); | |
| 428 | - | defer alloc.free(sesh); | |
| 429 | - | var daemon = Daemon{ | |
| 430 | - | .running = true, | |
| 431 | - | .cfg = &cfg, | |
| 432 | - | .alloc = alloc, | |
| 433 | - | .clients = clients, | |
| 434 | - | .session_name = sesh, | |
| 435 | - | .socket_path = undefined, | |
| 436 | - | .pid = undefined, | |
| 437 | - | .command = command, | |
| 438 | - | .cwd = cwd, | |
| 439 | - | .created_at = @intCast(std.time.timestamp()), | |
| 440 | - | }; | |
| 441 | - | daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh); | |
| 442 | - | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 443 | - | return attach(&daemon); | |
| 444 | - | } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) { | |
| 445 | - | const session_name = args.next() orelse ""; | |
| 446 | - | ||
| 447 | - | var cmd_args_raw: std.ArrayList([]const u8) = .empty; | |
| 448 | - | defer cmd_args_raw.deinit(alloc); | |
| 449 | - | while (args.next()) |arg| { | |
| 450 | - | try cmd_args_raw.append(alloc, arg); | |
| 451 | - | } | |
| 452 | - | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); | |
| 453 | - | ||
| 454 | - | var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 455 | - | const cwd = std.posix.getcwd(&cwd_buf) catch ""; | |
| 456 | - | ||
| 457 | - | const sesh = try getSeshName(alloc, session_name); | |
| 458 | - | defer alloc.free(sesh); | |
| 459 | - | var daemon = Daemon{ | |
| 460 | - | .running = true, | |
| 461 | - | .cfg = &cfg, | |
| 462 | - | .alloc = alloc, | |
| 463 | - | .clients = clients, | |
| 464 | - | .session_name = sesh, | |
| 465 | - | .socket_path = undefined, | |
| 466 | - | .pid = undefined, | |
| 467 | - | .command = null, | |
| 468 | - | .cwd = cwd, | |
| 469 | - | .created_at = @intCast(std.time.timestamp()), | |
| 470 | - | .is_task_mode = true, | |
| 471 | - | .task_command = cmd_args_raw.items, | |
| 472 | - | }; | |
| 473 | - | daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh); | |
| 474 | - | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 475 | - | return run(&daemon, cmd_args_raw.items); | |
| 476 | - | } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) { | |
| 477 | - | var args_raw: std.ArrayList([]const u8) = .empty; | |
| 478 | - | defer { | |
| 479 | - | for (args_raw.items) |sesh| { | |
| 480 | - | alloc.free(sesh); | |
| 481 | - | } | |
| 482 | - | args_raw.deinit(alloc); | |
| 483 | - | } | |
| 484 | - | while (args.next()) |session_name| { | |
| 485 | - | const sesh = try getSeshName(alloc, session_name); | |
| 486 | - | try args_raw.append(alloc, sesh); | |
| 487 | - | } | |
| 488 | - | return wait(&cfg, args_raw); | |
| 489 | - | } else { | |
| 490 | - | return help(); | |
| 491 | - | } | |
| 492 | - | } | |
| 493 | - | ||
| 494 | 580 | fn printVersion(cfg: *Cfg) !void { | |
| 495 | 581 | var buf: [256]u8 = undefined; | |
| 496 | 582 | var w = std.fs.File.stdout().writer(&buf); |
| ... | ... | @@ -546,23 +632,6 @@ fn help() !void { | |
| 546 | 632 | try w.interface.flush(); | |
| 547 | 633 | } | |
| 548 | 634 | ||
| 549 | - | const SessionEntry = struct { | |
| 550 | - | name: []const u8, | |
| 551 | - | pid: ?i32, | |
| 552 | - | clients_len: ?usize, | |
| 553 | - | is_error: bool, | |
| 554 | - | error_name: ?[]const u8, | |
| 555 | - | cmd: ?[]const u8 = null, | |
| 556 | - | cwd: ?[]const u8 = null, | |
| 557 | - | created_at: u64, | |
| 558 | - | task_ended_at: ?u64, | |
| 559 | - | task_exit_code: ?u8, | |
| 560 | - | ||
| 561 | - | fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool { | |
| 562 | - | return std.mem.order(u8, a.name, b.name) == .lt; | |
| 563 | - | } | |
| 564 | - | }; | |
| 565 | - | ||
| 566 | 635 | fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void { | |
| 567 | 636 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 568 | 637 | defer _ = gpa.deinit(); |
| ... | ... | @@ -573,7 +642,7 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void { | |
| 573 | 642 | const stdout = &stdout_writer.interface; | |
| 574 | 643 | ||
| 575 | 644 | while (true) { | |
| 576 | - | var sessions = try get_session_entries(alloc, cfg); | |
| 645 | + | var sessions = try util.get_session_entries(alloc, cfg.socket_dir); | |
| 577 | 646 | var total: i32 = 0; | |
| 578 | 647 | var done: i32 = 0; | |
| 579 | 648 | var agg_exit_code: u8 = 0; |
| ... | ... | @@ -602,7 +671,10 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void { | |
| 602 | 671 | done += 1; | |
| 603 | 672 | } | |
| 604 | 673 | ||
| 605 | - | session_entries_deinit(alloc, &sessions); | |
| 674 | + | for (sessions.items) |session| { | |
| 675 | + | session.deinit(alloc); | |
| 676 | + | } | |
| 677 | + | sessions.deinit(alloc); | |
| 606 | 678 | ||
| 607 | 679 | if (total == done) { | |
| 608 | 680 | try stdout.print("tasks completed!\n", .{}); |
| ... | ... | @@ -615,77 +687,6 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void { | |
| 615 | 687 | } | |
| 616 | 688 | } | |
| 617 | 689 | ||
| 618 | - | fn session_entries_deinit(alloc: std.mem.Allocator, sessions: *std.ArrayList(SessionEntry)) void { | |
| 619 | - | for (sessions.items) |session| { | |
| 620 | - | alloc.free(session.name); | |
| 621 | - | if (session.cmd) |cmd| alloc.free(cmd); | |
| 622 | - | if (session.cwd) |cwd| alloc.free(cwd); | |
| 623 | - | } | |
| 624 | - | sessions.deinit(alloc); | |
| 625 | - | } | |
| 626 | - | ||
| 627 | - | fn get_session_entries(alloc: std.mem.Allocator, cfg: *Cfg) !std.ArrayList(SessionEntry) { | |
| 628 | - | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true }); | |
| 629 | - | defer dir.close(); | |
| 630 | - | var iter = dir.iterate(); | |
| 631 | - | ||
| 632 | - | var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30); | |
| 633 | - | ||
| 634 | - | while (try iter.next()) |entry| { | |
| 635 | - | const exists = sessionExists(dir, entry.name) catch continue; | |
| 636 | - | if (exists) { | |
| 637 | - | const name = try alloc.dupe(u8, entry.name); | |
| 638 | - | errdefer alloc.free(name); | |
| 639 | - | ||
| 640 | - | const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name); | |
| 641 | - | defer alloc.free(socket_path); | |
| 642 | - | ||
| 643 | - | const result = probeSession(alloc, socket_path) catch |err| { | |
| 644 | - | try sessions.append(alloc, .{ | |
| 645 | - | .name = name, | |
| 646 | - | .pid = null, | |
| 647 | - | .clients_len = null, | |
| 648 | - | .is_error = true, | |
| 649 | - | .error_name = @errorName(err), | |
| 650 | - | .created_at = 0, | |
| 651 | - | .task_exit_code = 1, | |
| 652 | - | .task_ended_at = 0, | |
| 653 | - | }); | |
| 654 | - | cleanupStaleSocket(dir, entry.name); | |
| 655 | - | continue; | |
| 656 | - | }; | |
| 657 | - | posix.close(result.fd); | |
| 658 | - | ||
| 659 | - | // Extract cmd and cwd from the fixed-size arrays | |
| 660 | - | const cmd: ?[]const u8 = if (result.info.cmd_len > 0) | |
| 661 | - | alloc.dupe(u8, result.info.cmd[0..result.info.cmd_len]) catch null | |
| 662 | - | else | |
| 663 | - | null; | |
| 664 | - | const cwd: ?[]const u8 = if (result.info.cwd_len > 0) | |
| 665 | - | alloc.dupe(u8, result.info.cwd[0..result.info.cwd_len]) catch null | |
| 666 | - | else | |
| 667 | - | null; | |
| 668 | - | ||
| 669 | - | try sessions.append(alloc, .{ | |
| 670 | - | .name = name, | |
| 671 | - | .pid = result.info.pid, | |
| 672 | - | .clients_len = result.info.clients_len, | |
| 673 | - | .is_error = false, | |
| 674 | - | .error_name = null, | |
| 675 | - | .cmd = cmd, | |
| 676 | - | .cwd = cwd, | |
| 677 | - | .created_at = result.info.created_at, | |
| 678 | - | .task_ended_at = result.info.task_ended_at, | |
| 679 | - | .task_exit_code = result.info.task_exit_code, | |
| 680 | - | }); | |
| 681 | - | } | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | return sessions; | |
| 685 | - | } | |
| 686 | - | ||
| 687 | - | const current_arrow = "→"; | |
| 688 | - | ||
| 689 | 690 | fn list(cfg: *Cfg, short: bool) !void { | |
| 690 | 691 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 691 | 692 | defer _ = gpa.deinit(); |
| ... | ... | @@ -699,8 +700,13 @@ fn list(cfg: *Cfg, short: bool) !void { | |
| 699 | 700 | var buf: [4096]u8 = undefined; | |
| 700 | 701 | var w = std.fs.File.stdout().writer(&buf); | |
| 701 | 702 | ||
| 702 | - | var sessions = try get_session_entries(alloc, cfg); | |
| 703 | - | defer session_entries_deinit(alloc, &sessions); | |
| 703 | + | var sessions = try util.get_session_entries(alloc, cfg.socket_dir); | |
| 704 | + | defer { | |
| 705 | + | for (sessions.items) |session| { | |
| 706 | + | session.deinit(alloc); | |
| 707 | + | } | |
| 708 | + | sessions.deinit(alloc); | |
| 709 | + | } | |
| 704 | 710 | ||
| 705 | 711 | if (sessions.items.len == 0) { | |
| 706 | 712 | if (short) return; |
| ... | ... | @@ -709,10 +715,10 @@ fn list(cfg: *Cfg, short: bool) !void { | |
| 709 | 715 | return; | |
| 710 | 716 | } | |
| 711 | 717 | ||
| 712 | - | std.mem.sort(SessionEntry, sessions.items, {}, SessionEntry.lessThan); | |
| 718 | + | std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan); | |
| 713 | 719 | ||
| 714 | 720 | for (sessions.items) |session| { | |
| 715 | - | try writeSessionLine(&w.interface, session, short, current_session); | |
| 721 | + | try util.writeSessionLine(&w.interface, session, short, current_session); | |
| 716 | 722 | try w.interface.flush(); | |
| 717 | 723 | } | |
| 718 | 724 | } |
| ... | ... | @@ -733,11 +739,11 @@ fn detachAll(cfg: *Cfg) !void { | |
| 733 | 739 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); | |
| 734 | 740 | defer dir.close(); | |
| 735 | 741 | ||
| 736 | - | const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name); | |
| 742 | + | const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name); | |
| 737 | 743 | defer alloc.free(socket_path); | |
| 738 | - | const result = probeSession(alloc, socket_path) catch |err| { | |
| 744 | + | const result = ipc.probeSession(alloc, socket_path) catch |err| { | |
| 739 | 745 | std.log.err("session unresponsive: {s}", .{@errorName(err)}); | |
| 740 | - | cleanupStaleSocket(dir, session_name); | |
| 746 | + | socket.cleanupStaleSocket(dir, session_name); | |
| 741 | 747 | return; | |
| 742 | 748 | }; | |
| 743 | 749 | defer posix.close(result.fd); |
| ... | ... | @@ -755,17 +761,17 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void { | |
| 755 | 761 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); | |
| 756 | 762 | defer dir.close(); | |
| 757 | 763 | ||
| 758 | - | const exists = try sessionExists(dir, session_name); | |
| 764 | + | const exists = try socket.sessionExists(dir, session_name); | |
| 759 | 765 | if (!exists) { | |
| 760 | 766 | std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name}); | |
| 761 | 767 | return; | |
| 762 | 768 | } | |
| 763 | 769 | ||
| 764 | - | const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name); | |
| 770 | + | const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name); | |
| 765 | 771 | defer alloc.free(socket_path); | |
| 766 | - | const result = probeSession(alloc, socket_path) catch |err| { | |
| 772 | + | const result = ipc.probeSession(alloc, socket_path) catch |err| { | |
| 767 | 773 | std.log.err("session unresponsive: {s}", .{@errorName(err)}); | |
| 768 | - | cleanupStaleSocket(dir, session_name); | |
| 774 | + | socket.cleanupStaleSocket(dir, session_name); | |
| 769 | 775 | var buf: [4096]u8 = undefined; | |
| 770 | 776 | var w = std.fs.File.stdout().writer(&buf); | |
| 771 | 777 | w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {}; |
| ... | ... | @@ -784,13 +790,7 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void { | |
| 784 | 790 | try w.interface.flush(); | |
| 785 | 791 | } | |
| 786 | 792 | ||
| 787 | - | const HistoryFormat = enum(u8) { | |
| 788 | - | plain = 0, | |
| 789 | - | vt = 1, | |
| 790 | - | html = 2, | |
| 791 | - | }; | |
| 792 | - | ||
| 793 | - | fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void { | |
| 793 | + | fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void { | |
| 794 | 794 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 795 | 795 | defer _ = gpa.deinit(); | |
| 796 | 796 | const alloc = gpa.allocator(); |
| ... | ... | @@ -798,17 +798,17 @@ fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void { | |
| 798 | 798 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); | |
| 799 | 799 | defer dir.close(); | |
| 800 | 800 | ||
| 801 | - | const exists = try sessionExists(dir, session_name); | |
| 801 | + | const exists = try socket.sessionExists(dir, session_name); | |
| 802 | 802 | if (!exists) { | |
| 803 | 803 | std.log.err("session does not exist session_name={s}", .{session_name}); | |
| 804 | 804 | return; | |
| 805 | 805 | } | |
| 806 | 806 | ||
| 807 | - | const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name); | |
| 807 | + | const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name); | |
| 808 | 808 | defer alloc.free(socket_path); | |
| 809 | - | const result = probeSession(alloc, socket_path) catch |err| { | |
| 809 | + | const result = ipc.probeSession(alloc, socket_path) catch |err| { | |
| 810 | 810 | std.log.err("session unresponsive: {s}", .{@errorName(err)}); | |
| 811 | - | cleanupStaleSocket(dir, session_name); | |
| 811 | + | socket.cleanupStaleSocket(dir, session_name); | |
| 812 | 812 | return; | |
| 813 | 813 | }; | |
| 814 | 814 | defer posix.close(result.fd); |
| ... | ... | @@ -842,94 +842,28 @@ fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void { | |
| 842 | 842 | } | |
| 843 | 843 | } | |
| 844 | 844 | ||
| 845 | - | const EnsureSessionResult = struct { | |
| 846 | - | created: bool, | |
| 847 | - | is_daemon: bool, | |
| 848 | - | }; | |
| 849 | - | ||
| 850 | - | fn ensureSession(daemon: *Daemon) !EnsureSessionResult { | |
| 851 | - | var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{}); | |
| 852 | - | defer dir.close(); | |
| 853 | - | ||
| 854 | - | const exists = try sessionExists(dir, daemon.session_name); | |
| 855 | - | var should_create = !exists; | |
| 856 | - | ||
| 857 | - | if (exists) { | |
| 858 | - | if (probeSession(daemon.alloc, daemon.socket_path)) |result| { | |
| 859 | - | posix.close(result.fd); | |
| 860 | - | if (daemon.command != null) { | |
| 861 | - | std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name}); | |
| 862 | - | } | |
| 863 | - | } else |_| { | |
| 864 | - | cleanupStaleSocket(dir, daemon.session_name); | |
| 865 | - | should_create = true; | |
| 866 | - | } | |
| 867 | - | } | |
| 868 | - | ||
| 869 | - | if (should_create) { | |
| 870 | - | std.log.info("creating session={s}", .{daemon.session_name}); | |
| 871 | - | const server_sock_fd = try createSocket(daemon.socket_path); | |
| 872 | - | ||
| 873 | - | const pid = try posix.fork(); | |
| 874 | - | if (pid == 0) { // child (daemon) | |
| 875 | - | _ = try posix.setsid(); | |
| 876 | - | ||
| 877 | - | log_system.deinit(); | |
| 878 | - | const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name}); | |
| 879 | - | defer daemon.alloc.free(session_log_name); | |
| 880 | - | const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name }); | |
| 881 | - | defer daemon.alloc.free(session_log_path); | |
| 882 | - | try log_system.init(daemon.alloc, session_log_path); | |
| 883 | - | ||
| 884 | - | errdefer { | |
| 885 | - | posix.close(server_sock_fd); | |
| 886 | - | dir.deleteFile(daemon.session_name) catch {}; | |
| 887 | - | } | |
| 888 | - | const pty_fd = try spawnPty(daemon); | |
| 889 | - | defer { | |
| 890 | - | posix.close(pty_fd); | |
| 891 | - | posix.close(server_sock_fd); | |
| 892 | - | std.log.info("deleting socket file session_name={s}", .{daemon.session_name}); | |
| 893 | - | dir.deleteFile(daemon.session_name) catch |err| { | |
| 894 | - | std.log.warn("failed to delete socket file err={s}", .{@errorName(err)}); | |
| 895 | - | }; | |
| 896 | - | } | |
| 897 | - | try daemonLoop(daemon, server_sock_fd, pty_fd); | |
| 898 | - | daemon.handleKill(); | |
| 899 | - | _ = posix.waitpid(daemon.pid, 0); | |
| 900 | - | daemon.deinit(); | |
| 901 | - | return .{ .created = true, .is_daemon = true }; | |
| 902 | - | } | |
| 903 | - | posix.close(server_sock_fd); | |
| 904 | - | std.Thread.sleep(10 * std.time.ns_per_ms); | |
| 905 | - | return .{ .created = true, .is_daemon = false }; | |
| 906 | - | } | |
| 907 | - | ||
| 908 | - | return .{ .created = false, .is_daemon = false }; | |
| 909 | - | } | |
| 910 | - | ||
| 911 | 845 | fn attach(daemon: *Daemon) !void { | |
| 912 | 846 | if (std.posix.getenv("ZMX_SESSION")) |_| { | |
| 913 | 847 | return error.CannotAttachToSessionInSession; | |
| 914 | 848 | } | |
| 915 | 849 | ||
| 916 | - | const result = try ensureSession(daemon); | |
| 850 | + | const result = try daemon.ensureSession(); | |
| 917 | 851 | if (result.is_daemon) return; | |
| 918 | 852 | ||
| 919 | - | const client_sock = try sessionConnect(daemon.socket_path); | |
| 853 | + | const client_sock = try socket.sessionConnect(daemon.socket_path); | |
| 920 | 854 | std.log.info("attached session={s}", .{daemon.session_name}); | |
| 921 | 855 | // this is typically used with tcsetattr() to modify terminal settings. | |
| 922 | 856 | // - you first get the current settings with tcgetattr() | |
| 923 | 857 | // - modify the desired attributes in the termios structure | |
| 924 | 858 | // - then apply the changes with tcsetattr(). | |
| 925 | 859 | // This prevents unintended side effects by preserving other settings. | |
| 926 | - | var orig_termios: c.termios = undefined; | |
| 927 | - | _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios); | |
| 860 | + | var orig_termios: cross.c.termios = undefined; | |
| 861 | + | _ = cross.c.tcgetattr(posix.STDIN_FILENO, &orig_termios); | |
| 928 | 862 | ||
| 929 | 863 | // restore stdin fd to its original state after exiting. | |
| 930 | 864 | // Use TCSAFLUSH to discard any unread input, preventing stale input after detach. | |
| 931 | 865 | defer { | |
| 932 | - | _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSAFLUSH, &orig_termios); | |
| 866 | + | _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios); | |
| 933 | 867 | // Reset terminal modes on detach: | |
| 934 | 868 | // - Mouse: 1000=basic, 1002=button-event, 1003=any-event, 1006=SGR extended | |
| 935 | 869 | // - 2004=bracketed paste, 1004=focus events, 1049=alt screen |
| ... | ... | @@ -949,60 +883,24 @@ fn attach(daemon: *Daemon) !void { | |
| 949 | 883 | // set raw mode after successful connection. | |
| 950 | 884 | // disables canonical mode (line buffering), input echoing, signal generation from | |
| 951 | 885 | // control characters (like Ctrl+C), and flow control. | |
| 952 | - | c.cfmakeraw(&raw_termios); | |
| 886 | + | cross.c.cfmakeraw(&raw_termios); | |
| 953 | 887 | ||
| 954 | 888 | // Additional granular raw mode settings for precise control | |
| 955 | 889 | // (matches what abduco and shpool do) | |
| 956 | - | raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V) | |
| 890 | + | raw_termios.c_cc[cross.c.VLNEXT] = cross.c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V) | |
| 957 | 891 | // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key | |
| 958 | - | raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\) | |
| 959 | - | raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte | |
| 960 | - | raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately | |
| 892 | + | raw_termios.c_cc[cross.c.VQUIT] = cross.c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\) | |
| 893 | + | raw_termios.c_cc[cross.c.VMIN] = 1; // Minimum chars to read: return after 1 byte | |
| 894 | + | raw_termios.c_cc[cross.c.VTIME] = 0; // Read timeout: no timeout, return immediately | |
| 961 | 895 | ||
| 962 | - | _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios); | |
| 896 | + | _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios); | |
| 963 | 897 | ||
| 964 | 898 | // Clear screen before attaching. This provides a clean slate before | |
| 965 | 899 | // the session restore. | |
| 966 | 900 | const clear_seq = "\x1b[2J\x1b[H"; | |
| 967 | 901 | _ = try posix.write(posix.STDOUT_FILENO, clear_seq); | |
| 968 | 902 | ||
| 969 | - | try clientLoop(daemon.cfg, client_sock); | |
| 970 | - | } | |
| 971 | - | ||
| 972 | - | fn shellNeedsQuoting(arg: []const u8) bool { | |
| 973 | - | if (arg.len == 0) return true; | |
| 974 | - | for (arg) |ch| { | |
| 975 | - | switch (ch) { | |
| 976 | - | ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true, | |
| 977 | - | else => {}, | |
| 978 | - | } | |
| 979 | - | } | |
| 980 | - | return false; | |
| 981 | - | } | |
| 982 | - | ||
| 983 | - | fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 { | |
| 984 | - | // Always use single quotes (like Python's shlex.quote). Inside single | |
| 985 | - | // quotes nothing is special except ' itself, which we handle with the | |
| 986 | - | // '\'' trick (end quote, escaped literal quote, reopen quote). | |
| 987 | - | var len: usize = 2; | |
| 988 | - | for (arg) |ch| { | |
| 989 | - | len += if (ch == '\'') 4 else 1; | |
| 990 | - | } | |
| 991 | - | const buf = try alloc.alloc(u8, len); | |
| 992 | - | var i: usize = 0; | |
| 993 | - | buf[i] = '\''; | |
| 994 | - | i += 1; | |
| 995 | - | for (arg) |ch| { | |
| 996 | - | if (ch == '\'') { | |
| 997 | - | @memcpy(buf[i..][0..4], "'\\''"); | |
| 998 | - | i += 4; | |
| 999 | - | } else { | |
| 1000 | - | buf[i] = ch; | |
| 1001 | - | i += 1; | |
| 1002 | - | } | |
| 1003 | - | } | |
| 1004 | - | buf[i] = '\''; | |
| 1005 | - | return buf; | |
| 903 | + | try clientLoop(client_sock); | |
| 1006 | 904 | } | |
| 1007 | 905 | ||
| 1008 | 906 | fn run(daemon: *Daemon, command_args: [][]const u8) !void { |
| ... | ... | @@ -1014,7 +912,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1014 | 912 | var allocated_cmd: ?[]u8 = null; | |
| 1015 | 913 | defer if (allocated_cmd) |cmd| alloc.free(cmd); | |
| 1016 | 914 | ||
| 1017 | - | const result = try ensureSession(daemon); | |
| 915 | + | const result = try daemon.ensureSession(); | |
| 1018 | 916 | if (result.is_daemon) return; | |
| 1019 | 917 | ||
| 1020 | 918 | if (result.created) { |
| ... | ... | @@ -1022,7 +920,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1022 | 920 | try w.interface.flush(); | |
| 1023 | 921 | } | |
| 1024 | 922 | ||
| 1025 | - | const shell = detectShell(); | |
| 923 | + | const shell = util.detectShell(); | |
| 1026 | 924 | const shell_basename = std.fs.path.basename(shell); | |
| 1027 | 925 | const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish")) | |
| 1028 | 926 | "; echo ZMX_TASK_COMPLETED:$status" |
| ... | ... | @@ -1039,8 +937,8 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1039 | 937 | ||
| 1040 | 938 | for (command_args, 0..) |arg, i| { | |
| 1041 | 939 | if (i > 0) try cmd_list.append(alloc, ' '); | |
| 1042 | - | if (shellNeedsQuoting(arg)) { | |
| 1043 | - | const quoted = try shellQuote(alloc, arg); | |
| 940 | + | if (util.shellNeedsQuoting(arg)) { | |
| 941 | + | const quoted = try util.shellQuote(alloc, arg); | |
| 1044 | 942 | defer alloc.free(quoted); | |
| 1045 | 943 | try cmd_list.appendSlice(alloc, quoted); | |
| 1046 | 944 | } else { |
| ... | ... | @@ -1088,7 +986,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1088 | 986 | return error.CommandRequired; | |
| 1089 | 987 | } | |
| 1090 | 988 | ||
| 1091 | - | const probe_result = probeSession(alloc, daemon.socket_path) catch |err| { | |
| 989 | + | const probe_result = ipc.probeSession(alloc, daemon.socket_path) catch |err| { | |
| 1092 | 990 | std.log.err("session not ready: {s}", .{@errorName(err)}); | |
| 1093 | 991 | return error.SessionNotReady; | |
| 1094 | 992 | }; |
| ... | ... | @@ -1120,7 +1018,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1120 | 1018 | return error.NoAckReceived; | |
| 1121 | 1019 | } | |
| 1122 | 1020 | ||
| 1123 | - | fn clientLoop(_: *Cfg, client_sock_fd: i32) !void { | |
| 1021 | + | fn clientLoop(client_sock_fd: i32) !void { | |
| 1124 | 1022 | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking | |
| 1125 | 1023 | const alloc = std.heap.c_allocator; | |
| 1126 | 1024 | defer posix.close(client_sock_fd); |
| ... | ... | @@ -1136,7 +1034,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void { | |
| 1136 | 1034 | defer sock_write_buf.deinit(alloc); | |
| 1137 | 1035 | ||
| 1138 | 1036 | // Send init message with terminal size (buffered) | |
| 1139 | - | const size = getTerminalSize(posix.STDOUT_FILENO); | |
| 1037 | + | const size = ipc.getTerminalSize(posix.STDOUT_FILENO); | |
| 1140 | 1038 | try ipc.appendMessage(alloc, &sock_write_buf, .Init, std.mem.asBytes(&size)); | |
| 1141 | 1039 | ||
| 1142 | 1040 | var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 4); |
| ... | ... | @@ -1157,7 +1055,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void { | |
| 1157 | 1055 | while (true) { | |
| 1158 | 1056 | // Check for pending SIGWINCH | |
| 1159 | 1057 | if (sigwinch_received.swap(false, .acq_rel)) { | |
| 1160 | - | const next_size = getTerminalSize(posix.STDOUT_FILENO); | |
| 1058 | + | const next_size = ipc.getTerminalSize(posix.STDOUT_FILENO); | |
| 1161 | 1059 | try ipc.appendMessage(alloc, &sock_write_buf, .Resize, std.mem.asBytes(&next_size)); | |
| 1162 | 1060 | } | |
| 1163 | 1061 |
| ... | ... | @@ -1204,7 +1102,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void { | |
| 1204 | 1102 | if (n_opt) |n| { | |
| 1205 | 1103 | if (n > 0) { | |
| 1206 | 1104 | // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence) | |
| 1207 | - | if (buf[0] == 0x1C or isKittyCtrlBackslash(buf[0..n])) { | |
| 1105 | + | if (buf[0] == 0x1C or util.isKittyCtrlBackslash(buf[0..n])) { | |
| 1208 | 1106 | try ipc.appendMessage(alloc, &sock_write_buf, .Detach, ""); | |
| 1209 | 1107 | } else { | |
| 1210 | 1108 | try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]); |
| ... | ... | @@ -1274,80 +1172,13 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void { | |
| 1274 | 1172 | } | |
| 1275 | 1173 | } | |
| 1276 | 1174 | ||
| 1277 | - | const DA1_QUERY = "\x1b[c"; | |
| 1278 | - | const DA1_QUERY_EXPLICIT = "\x1b[0c"; | |
| 1279 | - | const DA2_QUERY = "\x1b[>c"; | |
| 1280 | - | const DA2_QUERY_EXPLICIT = "\x1b[>0c"; | |
| 1281 | - | const DA1_RESPONSE = "\x1b[?62;22c"; | |
| 1282 | - | const DA2_RESPONSE = "\x1b[>1;10;0c"; | |
| 1283 | - | ||
| 1284 | - | fn respondToDeviceAttributes(pty_fd: i32, data: []const u8) void { | |
| 1285 | - | // Scan for DA queries in PTY output and respond on behalf of the terminal. | |
| 1286 | - | // This handles the case where no client is attached (e.g. zmx run) | |
| 1287 | - | // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered. | |
| 1288 | - | // | |
| 1289 | - | // DA1 query: ESC [ c or ESC [ 0 c | |
| 1290 | - | // DA2 query: ESC [ > c or ESC [ > 0 c | |
| 1291 | - | // DA1 response (from terminal): ESC [ ? ... c (has '?' after '[') | |
| 1292 | - | // | |
| 1293 | - | // We must NOT match DA responses (which contain '?') as queries. | |
| 1294 | - | var i: usize = 0; | |
| 1295 | - | while (i < data.len) { | |
| 1296 | - | if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') { | |
| 1297 | - | // Skip DA responses which have '?' after CSI | |
| 1298 | - | if (i + 2 < data.len and data[i + 2] == '?') { | |
| 1299 | - | i += 3; | |
| 1300 | - | continue; | |
| 1301 | - | } | |
| 1302 | - | if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) { | |
| 1303 | - | _ = posix.write(pty_fd, DA2_RESPONSE) catch {}; | |
| 1304 | - | } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) { | |
| 1305 | - | _ = posix.write(pty_fd, DA1_RESPONSE) catch {}; | |
| 1306 | - | } | |
| 1307 | - | } | |
| 1308 | - | i += 1; | |
| 1309 | - | } | |
| 1310 | - | } | |
| 1311 | - | ||
| 1312 | - | fn matchSeq(data: []const u8, seq: []const u8) bool { | |
| 1313 | - | if (data.len < seq.len) return false; | |
| 1314 | - | return std.mem.eql(u8, data[0..seq.len], seq); | |
| 1315 | - | } | |
| 1316 | - | ||
| 1317 | - | fn findTaskExitMarker(output: []const u8) ?u8 { | |
| 1318 | - | const marker = "ZMX_TASK_COMPLETED:"; | |
| 1319 | - | ||
| 1320 | - | // Search for marker in output | |
| 1321 | - | if (std.mem.indexOf(u8, output, marker)) |idx| { | |
| 1322 | - | const after_marker = output[idx + marker.len ..]; | |
| 1323 | - | ||
| 1324 | - | // Find the exit code number and newline | |
| 1325 | - | var end_idx: usize = 0; | |
| 1326 | - | while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') { | |
| 1327 | - | end_idx += 1; | |
| 1328 | - | } | |
| 1329 | - | ||
| 1330 | - | const exit_code_str = after_marker[0..end_idx]; | |
| 1331 | - | ||
| 1332 | - | // Parse exit code | |
| 1333 | - | if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| { | |
| 1334 | - | return exit_code; | |
| 1335 | - | } else |_| { | |
| 1336 | - | std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str}); | |
| 1337 | - | return null; | |
| 1338 | - | } | |
| 1339 | - | } | |
| 1340 | - | ||
| 1341 | - | return null; | |
| 1342 | - | } | |
| 1343 | - | ||
| 1344 | 1175 | fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 1345 | 1176 | std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd }); | |
| 1346 | 1177 | setupSigtermHandler(); | |
| 1347 | 1178 | var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8); | |
| 1348 | 1179 | defer poll_fds.deinit(daemon.alloc); | |
| 1349 | 1180 | ||
| 1350 | - | const init_size = getTerminalSize(pty_fd); | |
| 1181 | + | const init_size = ipc.getTerminalSize(pty_fd); | |
| 1351 | 1182 | var term = try ghostty_vt.Terminal.init(daemon.alloc, .{ | |
| 1352 | 1183 | .cols = init_size.cols, | |
| 1353 | 1184 | .rows = init_size.rows, |
| ... | ... | @@ -1434,12 +1265,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 1434 | 1265 | // and then sending a no DA query response warning because | |
| 1435 | 1266 | // there's no client terminal to respond to the query. | |
| 1436 | 1267 | if (daemon.clients.items.len == 0) { | |
| 1437 | - | respondToDeviceAttributes(pty_fd, buf[0..n]); | |
| 1268 | + | util.respondToDeviceAttributes(pty_fd, buf[0..n]); | |
| 1438 | 1269 | } | |
| 1439 | 1270 | ||
| 1440 | 1271 | // In run mode, scan output for exit code marker | |
| 1441 | 1272 | if (daemon.is_task_mode and daemon.task_exit_code == null) { | |
| 1442 | - | if (findTaskExitMarker(buf[0..n])) |exit_code| { | |
| 1273 | + | if (util.findTaskExitMarker(buf[0..n])) |exit_code| { | |
| 1443 | 1274 | daemon.task_exit_code = exit_code; | |
| 1444 | 1275 | daemon.task_ended_at = @intCast(std.time.timestamp()); | |
| 1445 | 1276 |
| ... | ... | @@ -1542,169 +1373,6 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 1542 | 1373 | } | |
| 1543 | 1374 | } | |
| 1544 | 1375 | ||
| 1545 | - | fn spawnPty(daemon: *Daemon) !c_int { | |
| 1546 | - | const size = getTerminalSize(posix.STDOUT_FILENO); | |
| 1547 | - | var ws: c.struct_winsize = .{ | |
| 1548 | - | .ws_row = size.rows, | |
| 1549 | - | .ws_col = size.cols, | |
| 1550 | - | .ws_xpixel = 0, | |
| 1551 | - | .ws_ypixel = 0, | |
| 1552 | - | }; | |
| 1553 | - | ||
| 1554 | - | var master_fd: c_int = undefined; | |
| 1555 | - | const pid = forkpty(&master_fd, null, null, &ws); | |
| 1556 | - | if (pid < 0) { | |
| 1557 | - | return error.ForkPtyFailed; | |
| 1558 | - | } | |
| 1559 | - | ||
| 1560 | - | if (pid == 0) { // child pid code path | |
| 1561 | - | const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name}); | |
| 1562 | - | _ = c.putenv(@ptrCast(session_env.ptr)); | |
| 1563 | - | ||
| 1564 | - | if (daemon.command) |cmd_args| { | |
| 1565 | - | const alloc = std.heap.c_allocator; | |
| 1566 | - | var argv_buf: [64:null]?[*:0]const u8 = undefined; | |
| 1567 | - | for (cmd_args, 0..) |arg, i| { | |
| 1568 | - | argv_buf[i] = alloc.dupeZ(u8, arg) catch { | |
| 1569 | - | std.posix.exit(1); | |
| 1570 | - | }; | |
| 1571 | - | } | |
| 1572 | - | argv_buf[cmd_args.len] = null; | |
| 1573 | - | const argv: [*:null]const ?[*:0]const u8 = &argv_buf; | |
| 1574 | - | const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ); | |
| 1575 | - | std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) }); | |
| 1576 | - | std.posix.exit(1); | |
| 1577 | - | } else { | |
| 1578 | - | const shell = detectShell(); | |
| 1579 | - | // Use "-shellname" as argv[0] to signal login shell (traditional method) | |
| 1580 | - | var buf: [64]u8 = undefined; | |
| 1581 | - | const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)}); | |
| 1582 | - | const argv = [_:null]?[*:0]const u8{ login_shell, null }; | |
| 1583 | - | const err = std.posix.execveZ(shell, &argv, std.c.environ); | |
| 1584 | - | std.log.err("execve failed: err={s}", .{@errorName(err)}); | |
| 1585 | - | std.posix.exit(1); | |
| 1586 | - | } | |
| 1587 | - | } | |
| 1588 | - | // master pid code path | |
| 1589 | - | daemon.pid = pid; | |
| 1590 | - | std.log.info("pty spawned session={s} pid={d}", .{ daemon.session_name, pid }); | |
| 1591 | - | ||
| 1592 | - | // make pty non-blocking | |
| 1593 | - | const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0); | |
| 1594 | - | _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000)); | |
| 1595 | - | return master_fd; | |
| 1596 | - | } | |
| 1597 | - | ||
| 1598 | - | fn detectShell() [:0]const u8 { | |
| 1599 | - | return std.posix.getenv("SHELL") orelse "/bin/sh"; | |
| 1600 | - | } | |
| 1601 | - | ||
| 1602 | - | fn seshPrefix() []const u8 { | |
| 1603 | - | return std.posix.getenv("ZMX_SESSION_PREFIX") orelse ""; | |
| 1604 | - | } | |
| 1605 | - | ||
| 1606 | - | fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 { | |
| 1607 | - | const prefix = seshPrefix(); | |
| 1608 | - | if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) { | |
| 1609 | - | return error.SessionNameRequired; | |
| 1610 | - | } | |
| 1611 | - | return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh }); | |
| 1612 | - | } | |
| 1613 | - | ||
| 1614 | - | fn sessionConnect(sesh: []const u8) !i32 { | |
| 1615 | - | var unix_addr = try std.net.Address.initUnix(sesh); | |
| 1616 | - | const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0); | |
| 1617 | - | errdefer posix.close(socket_fd); | |
| 1618 | - | try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()); | |
| 1619 | - | return socket_fd; | |
| 1620 | - | } | |
| 1621 | - | ||
| 1622 | - | const SessionProbeError = error{ | |
| 1623 | - | Timeout, | |
| 1624 | - | ConnectionRefused, | |
| 1625 | - | Unexpected, | |
| 1626 | - | }; | |
| 1627 | - | ||
| 1628 | - | const SessionProbeResult = struct { | |
| 1629 | - | fd: i32, | |
| 1630 | - | info: ipc.Info, | |
| 1631 | - | }; | |
| 1632 | - | ||
| 1633 | - | fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult { | |
| 1634 | - | const timeout_ms = 1000; | |
| 1635 | - | const fd = sessionConnect(socket_path) catch |err| switch (err) { | |
| 1636 | - | error.ConnectionRefused => return error.ConnectionRefused, | |
| 1637 | - | else => return error.Unexpected, | |
| 1638 | - | }; | |
| 1639 | - | errdefer posix.close(fd); | |
| 1640 | - | ||
| 1641 | - | ipc.send(fd, .Info, "") catch return error.Unexpected; | |
| 1642 | - | ||
| 1643 | - | var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; | |
| 1644 | - | const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected; | |
| 1645 | - | if (poll_result == 0) { | |
| 1646 | - | return error.Timeout; | |
| 1647 | - | } | |
| 1648 | - | ||
| 1649 | - | var sb = ipc.SocketBuffer.init(alloc) catch return error.Unexpected; | |
| 1650 | - | defer sb.deinit(); | |
| 1651 | - | ||
| 1652 | - | const n = sb.read(fd) catch return error.Unexpected; | |
| 1653 | - | if (n == 0) return error.Unexpected; | |
| 1654 | - | ||
| 1655 | - | while (sb.next()) |msg| { | |
| 1656 | - | if (msg.header.tag == .Info) { | |
| 1657 | - | if (msg.payload.len == @sizeOf(ipc.Info)) { | |
| 1658 | - | return .{ | |
| 1659 | - | .fd = fd, | |
| 1660 | - | .info = std.mem.bytesToValue(ipc.Info, msg.payload[0..@sizeOf(ipc.Info)]), | |
| 1661 | - | }; | |
| 1662 | - | } | |
| 1663 | - | } | |
| 1664 | - | } | |
| 1665 | - | return error.Unexpected; | |
| 1666 | - | } | |
| 1667 | - | ||
| 1668 | - | fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void { | |
| 1669 | - | std.log.warn("stale socket found, cleaning up session={s}", .{session_name}); | |
| 1670 | - | dir.deleteFile(session_name) catch |err| { | |
| 1671 | - | std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)}); | |
| 1672 | - | }; | |
| 1673 | - | } | |
| 1674 | - | ||
| 1675 | - | fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool { | |
| 1676 | - | const stat = dir.statFile(name) catch |err| switch (err) { | |
| 1677 | - | error.FileNotFound => return false, | |
| 1678 | - | else => return err, | |
| 1679 | - | }; | |
| 1680 | - | if (stat.kind != .unix_domain_socket) { | |
| 1681 | - | return error.FileNotUnixSocket; | |
| 1682 | - | } | |
| 1683 | - | return true; | |
| 1684 | - | } | |
| 1685 | - | ||
| 1686 | - | fn createSocket(fname: []const u8) !i32 { | |
| 1687 | - | // AF.UNIX: Unix domain socket for local IPC with client processes | |
| 1688 | - | // SOCK.STREAM: Reliable, bidirectional communication | |
| 1689 | - | // SOCK.NONBLOCK: Set socket to non-blocking | |
| 1690 | - | const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0); | |
| 1691 | - | errdefer posix.close(fd); | |
| 1692 | - | ||
| 1693 | - | var unix_addr = try std.net.Address.initUnix(fname); | |
| 1694 | - | try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen()); | |
| 1695 | - | try posix.listen(fd, 128); | |
| 1696 | - | return fd; | |
| 1697 | - | } | |
| 1698 | - | ||
| 1699 | - | pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 { | |
| 1700 | - | const dir = socket_dir; | |
| 1701 | - | const fname = try alloc.alloc(u8, dir.len + session_name.len + 1); | |
| 1702 | - | @memcpy(fname[0..dir.len], dir); | |
| 1703 | - | @memcpy(fname[dir.len .. dir.len + 1], "/"); | |
| 1704 | - | @memcpy(fname[dir.len + 1 ..], session_name); | |
| 1705 | - | return fname; | |
| 1706 | - | } | |
| 1707 | - | ||
| 1708 | 1376 | fn handleSigwinch(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void { | |
| 1709 | 1377 | sigwinch_received.store(true, .release); | |
| 1710 | 1378 | } |
| ... | ... | @@ -1730,320 +1398,3 @@ fn setupSigtermHandler() void { | |
| 1730 | 1398 | }; | |
| 1731 | 1399 | posix.sigaction(posix.SIG.TERM, &act, null); | |
| 1732 | 1400 | } | |
| 1733 | - | ||
| 1734 | - | fn getTerminalSize(fd: i32) ipc.Resize { | |
| 1735 | - | var ws: c.struct_winsize = undefined; | |
| 1736 | - | if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) { | |
| 1737 | - | return .{ .rows = ws.ws_row, .cols = ws.ws_col }; | |
| 1738 | - | } | |
| 1739 | - | return .{ .rows = 24, .cols = 80 }; | |
| 1740 | - | } | |
| 1741 | - | ||
| 1742 | - | /// Formats a session entry for list output (only the name when `short` is | |
| 1743 | - | /// true), adding a prefix to indicate the current session, if there is one. | |
| 1744 | - | fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool, current_session: ?[]const u8) !void { | |
| 1745 | - | const prefix = if (current_session) |current| | |
| 1746 | - | if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else " " | |
| 1747 | - | else | |
| 1748 | - | ""; | |
| 1749 | - | ||
| 1750 | - | if (short) { | |
| 1751 | - | if (session.is_error) return; | |
| 1752 | - | try writer.print("{s}\n", .{session.name}); | |
| 1753 | - | return; | |
| 1754 | - | } | |
| 1755 | - | ||
| 1756 | - | if (session.is_error) { | |
| 1757 | - | try writer.print("{s}name={s}\terr={s}\tstatus=cleaning up\n", .{ | |
| 1758 | - | prefix, | |
| 1759 | - | session.name, | |
| 1760 | - | session.error_name.?, | |
| 1761 | - | }); | |
| 1762 | - | return; | |
| 1763 | - | } | |
| 1764 | - | ||
| 1765 | - | try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{ | |
| 1766 | - | prefix, | |
| 1767 | - | session.name, | |
| 1768 | - | session.pid.?, | |
| 1769 | - | session.clients_len.?, | |
| 1770 | - | session.created_at, | |
| 1771 | - | }); | |
| 1772 | - | if (session.cwd) |cwd| { | |
| 1773 | - | try writer.print("\tstart_dir={s}", .{cwd}); | |
| 1774 | - | } | |
| 1775 | - | if (session.cmd) |cmd| { | |
| 1776 | - | try writer.print("\tcmd={s}", .{cmd}); | |
| 1777 | - | } | |
| 1778 | - | if (session.task_ended_at) |ended_at| { | |
| 1779 | - | if (ended_at > 0) { | |
| 1780 | - | try writer.print("\tended={d}", .{ended_at}); | |
| 1781 | - | ||
| 1782 | - | if (session.task_exit_code) |exit_code| { | |
| 1783 | - | try writer.print("\texit_code={d}", .{exit_code}); | |
| 1784 | - | } | |
| 1785 | - | } | |
| 1786 | - | } | |
| 1787 | - | try writer.print("\n", .{}); | |
| 1788 | - | } | |
| 1789 | - | ||
| 1790 | - | /// Detects Kitty keyboard protocol escape sequence for Ctrl+\ | |
| 1791 | - | /// 92 = backslash, 5 = ctrl modifier, :1 = key press event | |
| 1792 | - | fn isKittyCtrlBackslash(buf: []const u8) bool { | |
| 1793 | - | return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or | |
| 1794 | - | std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null; | |
| 1795 | - | } | |
| 1796 | - | ||
| 1797 | - | fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 { | |
| 1798 | - | var builder: std.Io.Writer.Allocating = .init(alloc); | |
| 1799 | - | defer builder.deinit(); | |
| 1800 | - | ||
| 1801 | - | var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt); | |
| 1802 | - | term_formatter.content = .{ .selection = null }; | |
| 1803 | - | term_formatter.extra = .{ | |
| 1804 | - | .palette = false, | |
| 1805 | - | .modes = true, | |
| 1806 | - | .scrolling_region = true, | |
| 1807 | - | .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position | |
| 1808 | - | .pwd = true, | |
| 1809 | - | .keyboard = true, | |
| 1810 | - | .screen = .all, | |
| 1811 | - | }; | |
| 1812 | - | ||
| 1813 | - | term_formatter.format(&builder.writer) catch |err| { | |
| 1814 | - | std.log.warn("failed to format terminal state err={s}", .{@errorName(err)}); | |
| 1815 | - | return null; | |
| 1816 | - | }; | |
| 1817 | - | ||
| 1818 | - | const output = builder.writer.buffered(); | |
| 1819 | - | if (output.len == 0) return null; | |
| 1820 | - | ||
| 1821 | - | return alloc.dupe(u8, output) catch |err| { | |
| 1822 | - | std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)}); | |
| 1823 | - | return null; | |
| 1824 | - | }; | |
| 1825 | - | } | |
| 1826 | - | ||
| 1827 | - | fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 { | |
| 1828 | - | var builder: std.Io.Writer.Allocating = .init(alloc); | |
| 1829 | - | defer builder.deinit(); | |
| 1830 | - | ||
| 1831 | - | const opts: ghostty_vt.formatter.Options = switch (format) { | |
| 1832 | - | .plain => .plain, | |
| 1833 | - | .vt => .vt, | |
| 1834 | - | .html => .html, | |
| 1835 | - | }; | |
| 1836 | - | var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts); | |
| 1837 | - | term_formatter.content = .{ .selection = null }; | |
| 1838 | - | term_formatter.extra = switch (format) { | |
| 1839 | - | .plain => .none, | |
| 1840 | - | .vt => .{ | |
| 1841 | - | .palette = false, | |
| 1842 | - | .modes = true, | |
| 1843 | - | .scrolling_region = true, | |
| 1844 | - | .tabstops = false, | |
| 1845 | - | .pwd = true, | |
| 1846 | - | .keyboard = true, | |
| 1847 | - | .screen = .all, | |
| 1848 | - | }, | |
| 1849 | - | .html => .styles, | |
| 1850 | - | }; | |
| 1851 | - | ||
| 1852 | - | term_formatter.format(&builder.writer) catch |err| { | |
| 1853 | - | std.log.warn("failed to format terminal err={s}", .{@errorName(err)}); | |
| 1854 | - | return null; | |
| 1855 | - | }; | |
| 1856 | - | ||
| 1857 | - | const output = builder.writer.buffered(); | |
| 1858 | - | if (output.len == 0) return null; | |
| 1859 | - | ||
| 1860 | - | return alloc.dupe(u8, output) catch |err| { | |
| 1861 | - | std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)}); | |
| 1862 | - | return null; | |
| 1863 | - | }; | |
| 1864 | - | } | |
| 1865 | - | ||
| 1866 | - | test "shellNeedsQuoting" { | |
| 1867 | - | try std.testing.expect(shellNeedsQuoting("")); | |
| 1868 | - | try std.testing.expect(shellNeedsQuoting("hello world")); | |
| 1869 | - | try std.testing.expect(shellNeedsQuoting("hello!")); | |
| 1870 | - | try std.testing.expect(shellNeedsQuoting("$PATH")); | |
| 1871 | - | try std.testing.expect(shellNeedsQuoting("it's")); | |
| 1872 | - | try std.testing.expect(shellNeedsQuoting("a|b")); | |
| 1873 | - | try std.testing.expect(shellNeedsQuoting("a;b")); | |
| 1874 | - | try std.testing.expect(!shellNeedsQuoting("hello")); | |
| 1875 | - | try std.testing.expect(!shellNeedsQuoting("bash")); | |
| 1876 | - | try std.testing.expect(!shellNeedsQuoting("-c")); | |
| 1877 | - | try std.testing.expect(!shellNeedsQuoting("/usr/bin/env")); | |
| 1878 | - | } | |
| 1879 | - | ||
| 1880 | - | test "shellQuote" { | |
| 1881 | - | const alloc = std.testing.allocator; | |
| 1882 | - | ||
| 1883 | - | const empty = try shellQuote(alloc, ""); | |
| 1884 | - | defer alloc.free(empty); | |
| 1885 | - | try std.testing.expectEqualStrings("''", empty); | |
| 1886 | - | ||
| 1887 | - | const space = try shellQuote(alloc, "hello world"); | |
| 1888 | - | defer alloc.free(space); | |
| 1889 | - | try std.testing.expectEqualStrings("'hello world'", space); | |
| 1890 | - | ||
| 1891 | - | const bang = try shellQuote(alloc, "hello!"); | |
| 1892 | - | defer alloc.free(bang); | |
| 1893 | - | try std.testing.expectEqualStrings("'hello!'", bang); | |
| 1894 | - | ||
| 1895 | - | const dollar = try shellQuote(alloc, "$PATH"); | |
| 1896 | - | defer alloc.free(dollar); | |
| 1897 | - | try std.testing.expectEqualStrings("'$PATH'", dollar); | |
| 1898 | - | ||
| 1899 | - | const sq = try shellQuote(alloc, "it's"); | |
| 1900 | - | defer alloc.free(sq); | |
| 1901 | - | try std.testing.expectEqualStrings("'it'\\''s'", sq); | |
| 1902 | - | ||
| 1903 | - | const dq = try shellQuote(alloc, "say \"hi\""); | |
| 1904 | - | defer alloc.free(dq); | |
| 1905 | - | try std.testing.expectEqualStrings("'say \"hi\"'", dq); | |
| 1906 | - | ||
| 1907 | - | const both = try shellQuote(alloc, "it's \"cool\""); | |
| 1908 | - | defer alloc.free(both); | |
| 1909 | - | try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both); | |
| 1910 | - | ||
| 1911 | - | // just a single quote | |
| 1912 | - | const lone_sq = try shellQuote(alloc, "'"); | |
| 1913 | - | defer alloc.free(lone_sq); | |
| 1914 | - | try std.testing.expectEqualStrings("''\\'''", lone_sq); | |
| 1915 | - | ||
| 1916 | - | // multiple consecutive single quotes | |
| 1917 | - | const triple_sq = try shellQuote(alloc, "'''"); | |
| 1918 | - | defer alloc.free(triple_sq); | |
| 1919 | - | try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq); | |
| 1920 | - | ||
| 1921 | - | // backtick command substitution | |
| 1922 | - | const backtick = try shellQuote(alloc, "`whoami`"); | |
| 1923 | - | defer alloc.free(backtick); | |
| 1924 | - | try std.testing.expectEqualStrings("'`whoami`'", backtick); | |
| 1925 | - | ||
| 1926 | - | // dollar command substitution | |
| 1927 | - | const dollar_cmd = try shellQuote(alloc, "$(whoami)"); | |
| 1928 | - | defer alloc.free(dollar_cmd); | |
| 1929 | - | try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd); | |
| 1930 | - | ||
| 1931 | - | // glob | |
| 1932 | - | const glob = try shellQuote(alloc, "*.txt"); | |
| 1933 | - | defer alloc.free(glob); | |
| 1934 | - | try std.testing.expectEqualStrings("'*.txt'", glob); | |
| 1935 | - | ||
| 1936 | - | // tilde | |
| 1937 | - | const tilde = try shellQuote(alloc, "~/file"); | |
| 1938 | - | defer alloc.free(tilde); | |
| 1939 | - | try std.testing.expectEqualStrings("'~/file'", tilde); | |
| 1940 | - | ||
| 1941 | - | // trailing backslash | |
| 1942 | - | const trailing_bs = try shellQuote(alloc, "path\\"); | |
| 1943 | - | defer alloc.free(trailing_bs); | |
| 1944 | - | try std.testing.expectEqualStrings("'path\\'", trailing_bs); | |
| 1945 | - | ||
| 1946 | - | // semicolon (command injection) | |
| 1947 | - | const semi = try shellQuote(alloc, "; rm -rf /"); | |
| 1948 | - | defer alloc.free(semi); | |
| 1949 | - | try std.testing.expectEqualStrings("'; rm -rf /'", semi); | |
| 1950 | - | ||
| 1951 | - | // embedded newline | |
| 1952 | - | const newline = try shellQuote(alloc, "line1\nline2"); | |
| 1953 | - | defer alloc.free(newline); | |
| 1954 | - | try std.testing.expectEqualStrings("'line1\nline2'", newline); | |
| 1955 | - | ||
| 1956 | - | // parentheses (subshell) | |
| 1957 | - | const parens = try shellQuote(alloc, "(echo hi)"); | |
| 1958 | - | defer alloc.free(parens); | |
| 1959 | - | try std.testing.expectEqualStrings("'(echo hi)'", parens); | |
| 1960 | - | ||
| 1961 | - | // heredoc marker | |
| 1962 | - | const heredoc = try shellQuote(alloc, "<<EOF"); | |
| 1963 | - | defer alloc.free(heredoc); | |
| 1964 | - | try std.testing.expectEqualStrings("'<<EOF'", heredoc); | |
| 1965 | - | ||
| 1966 | - | // no quoting needed -- plain word should still be quoted | |
| 1967 | - | // (shellQuote is only called when shellNeedsQuoting returns true, | |
| 1968 | - | // but verify it produces valid output anyway) | |
| 1969 | - | const plain = try shellQuote(alloc, "hello"); | |
| 1970 | - | defer alloc.free(plain); | |
| 1971 | - | try std.testing.expectEqualStrings("'hello'", plain); | |
| 1972 | - | } | |
| 1973 | - | ||
| 1974 | - | test "isKittyCtrlBackslash" { | |
| 1975 | - | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u")); | |
| 1976 | - | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u")); | |
| 1977 | - | try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u")); | |
| 1978 | - | try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u")); | |
| 1979 | - | try std.testing.expect(!isKittyCtrlBackslash("garbage")); | |
| 1980 | - | } | |
| 1981 | - | ||
| 1982 | - | test "writeSessionLine formats output for current session and short output" { | |
| 1983 | - | const Case = struct { | |
| 1984 | - | session: SessionEntry, | |
| 1985 | - | short: bool, | |
| 1986 | - | current_session: ?[]const u8, | |
| 1987 | - | expected: []const u8, | |
| 1988 | - | }; | |
| 1989 | - | ||
| 1990 | - | const session = SessionEntry{ | |
| 1991 | - | .name = "dev", | |
| 1992 | - | .pid = 123, | |
| 1993 | - | .clients_len = 2, | |
| 1994 | - | .is_error = false, | |
| 1995 | - | .error_name = null, | |
| 1996 | - | .cmd = null, | |
| 1997 | - | .cwd = null, | |
| 1998 | - | .created_at = 0, | |
| 1999 | - | .task_ended_at = null, | |
| 2000 | - | .task_exit_code = null, | |
| 2001 | - | }; | |
| 2002 | - | ||
| 2003 | - | const cases = [_]Case{ | |
| 2004 | - | .{ | |
| 2005 | - | .session = session, | |
| 2006 | - | .short = false, | |
| 2007 | - | .current_session = "dev", | |
| 2008 | - | .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n", | |
| 2009 | - | }, | |
| 2010 | - | .{ | |
| 2011 | - | .session = session, | |
| 2012 | - | .short = false, | |
| 2013 | - | .current_session = "other", | |
| 2014 | - | .expected = " name=dev\tpid=123\tclients=2\tcreated=0\n", | |
| 2015 | - | }, | |
| 2016 | - | .{ | |
| 2017 | - | .session = session, | |
| 2018 | - | .short = false, | |
| 2019 | - | .current_session = null, | |
| 2020 | - | .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n", | |
| 2021 | - | }, | |
| 2022 | - | .{ | |
| 2023 | - | .session = session, | |
| 2024 | - | .short = true, | |
| 2025 | - | .current_session = "dev", | |
| 2026 | - | .expected = "dev\n", | |
| 2027 | - | }, | |
| 2028 | - | .{ | |
| 2029 | - | .session = session, | |
| 2030 | - | .short = true, | |
| 2031 | - | .current_session = "other", | |
| 2032 | - | .expected = "dev\n", | |
| 2033 | - | }, | |
| 2034 | - | .{ | |
| 2035 | - | .session = session, | |
| 2036 | - | .short = true, | |
| 2037 | - | .current_session = null, | |
| 2038 | - | .expected = "dev\n", | |
| 2039 | - | }, | |
| 2040 | - | }; | |
| 2041 | - | ||
| 2042 | - | for (cases) |case| { | |
| 2043 | - | var builder: std.Io.Writer.Allocating = .init(std.testing.allocator); | |
| 2044 | - | defer builder.deinit(); | |
| 2045 | - | ||
| 2046 | - | try writeSessionLine(&builder.writer, case.session, case.short, case.current_session); | |
| 2047 | - | try std.testing.expectEqualStrings(case.expected, builder.writer.buffered()); | |
| 2048 | - | } | |
| 2049 | - | } |
+62,
-0
| ... | ... | @@ -0,0 +1,62 @@ | |
| 1 | + | const std = @import("std"); | |
| 2 | + | const posix = std.posix; | |
| 3 | + | ||
| 4 | + | pub fn seshPrefix() []const u8 { | |
| 5 | + | return std.posix.getenv("ZMX_SESSION_PREFIX") orelse ""; | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 { | |
| 9 | + | const prefix = seshPrefix(); | |
| 10 | + | if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) { | |
| 11 | + | return error.SessionNameRequired; | |
| 12 | + | } | |
| 13 | + | return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh }); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | pub fn sessionConnect(sesh: []const u8) !i32 { | |
| 17 | + | var unix_addr = try std.net.Address.initUnix(sesh); | |
| 18 | + | const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0); | |
| 19 | + | errdefer posix.close(socket_fd); | |
| 20 | + | try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()); | |
| 21 | + | return socket_fd; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | pub fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void { | |
| 25 | + | std.log.warn("stale socket found, cleaning up session={s}", .{session_name}); | |
| 26 | + | dir.deleteFile(session_name) catch |err| { | |
| 27 | + | std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)}); | |
| 28 | + | }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | pub fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool { | |
| 32 | + | const stat = dir.statFile(name) catch |err| switch (err) { | |
| 33 | + | error.FileNotFound => return false, | |
| 34 | + | else => return err, | |
| 35 | + | }; | |
| 36 | + | if (stat.kind != .unix_domain_socket) { | |
| 37 | + | return error.FileNotUnixSocket; | |
| 38 | + | } | |
| 39 | + | return true; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub fn createSocket(fname: []const u8) !i32 { | |
| 43 | + | // AF.UNIX: Unix domain socket for local IPC with client processes | |
| 44 | + | // SOCK.STREAM: Reliable, bidirectional communication | |
| 45 | + | // SOCK.NONBLOCK: Set socket to non-blocking | |
| 46 | + | const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0); | |
| 47 | + | errdefer posix.close(fd); | |
| 48 | + | ||
| 49 | + | var unix_addr = try std.net.Address.initUnix(fname); | |
| 50 | + | try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen()); | |
| 51 | + | try posix.listen(fd, 128); | |
| 52 | + | return fd; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 { | |
| 56 | + | const dir = socket_dir; | |
| 57 | + | const fname = try alloc.alloc(u8, dir.len + session_name.len + 1); | |
| 58 | + | @memcpy(fname[0..dir.len], dir); | |
| 59 | + | @memcpy(fname[dir.len .. dir.len + 1], "/"); | |
| 60 | + | @memcpy(fname[dir.len + 1 ..], session_name); | |
| 61 | + | return fname; | |
| 62 | + | } |
+511,
-0
| ... | ... | @@ -0,0 +1,511 @@ | |
| 1 | + | const std = @import("std"); | |
| 2 | + | const posix = std.posix; | |
| 3 | + | const ghostty_vt = @import("ghostty-vt"); | |
| 4 | + | const ipc = @import("ipc.zig"); | |
| 5 | + | const socket = @import("socket.zig"); | |
| 6 | + | ||
| 7 | + | pub const SessionEntry = struct { | |
| 8 | + | name: []const u8, | |
| 9 | + | pid: ?i32, | |
| 10 | + | clients_len: ?usize, | |
| 11 | + | is_error: bool, | |
| 12 | + | error_name: ?[]const u8, | |
| 13 | + | cmd: ?[]const u8 = null, | |
| 14 | + | cwd: ?[]const u8 = null, | |
| 15 | + | created_at: u64, | |
| 16 | + | task_ended_at: ?u64, | |
| 17 | + | task_exit_code: ?u8, | |
| 18 | + | ||
| 19 | + | pub fn deinit(self: SessionEntry, alloc: std.mem.Allocator) void { | |
| 20 | + | alloc.free(self.name); | |
| 21 | + | if (self.cmd) |cmd| alloc.free(cmd); | |
| 22 | + | if (self.cwd) |cwd| alloc.free(cwd); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool { | |
| 26 | + | return std.mem.order(u8, a.name, b.name) == .lt; | |
| 27 | + | } | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | pub fn get_session_entries(alloc: std.mem.Allocator, socket_dir: []const u8) !std.ArrayList(SessionEntry) { | |
| 31 | + | var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true }); | |
| 32 | + | defer dir.close(); | |
| 33 | + | var iter = dir.iterate(); | |
| 34 | + | ||
| 35 | + | var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30); | |
| 36 | + | ||
| 37 | + | while (try iter.next()) |entry| { | |
| 38 | + | const exists = socket.sessionExists(dir, entry.name) catch continue; | |
| 39 | + | if (exists) { | |
| 40 | + | const name = try alloc.dupe(u8, entry.name); | |
| 41 | + | errdefer alloc.free(name); | |
| 42 | + | ||
| 43 | + | const socket_path = try socket.getSocketPath(alloc, socket_dir, entry.name); | |
| 44 | + | defer alloc.free(socket_path); | |
| 45 | + | ||
| 46 | + | const result = ipc.probeSession(alloc, socket_path) catch |err| { | |
| 47 | + | try sessions.append(alloc, .{ | |
| 48 | + | .name = name, | |
| 49 | + | .pid = null, | |
| 50 | + | .clients_len = null, | |
| 51 | + | .is_error = true, | |
| 52 | + | .error_name = @errorName(err), | |
| 53 | + | .created_at = 0, | |
| 54 | + | .task_exit_code = 1, | |
| 55 | + | .task_ended_at = 0, | |
| 56 | + | }); | |
| 57 | + | socket.cleanupStaleSocket(dir, entry.name); | |
| 58 | + | continue; | |
| 59 | + | }; | |
| 60 | + | posix.close(result.fd); | |
| 61 | + | ||
| 62 | + | // Extract cmd and cwd from the fixed-size arrays | |
| 63 | + | const cmd: ?[]const u8 = if (result.info.cmd_len > 0) | |
| 64 | + | alloc.dupe(u8, result.info.cmd[0..result.info.cmd_len]) catch null | |
| 65 | + | else | |
| 66 | + | null; | |
| 67 | + | const cwd: ?[]const u8 = if (result.info.cwd_len > 0) | |
| 68 | + | alloc.dupe(u8, result.info.cwd[0..result.info.cwd_len]) catch null | |
| 69 | + | else | |
| 70 | + | null; | |
| 71 | + | ||
| 72 | + | try sessions.append(alloc, .{ | |
| 73 | + | .name = name, | |
| 74 | + | .pid = result.info.pid, | |
| 75 | + | .clients_len = result.info.clients_len, | |
| 76 | + | .is_error = false, | |
| 77 | + | .error_name = null, | |
| 78 | + | .cmd = cmd, | |
| 79 | + | .cwd = cwd, | |
| 80 | + | .created_at = result.info.created_at, | |
| 81 | + | .task_ended_at = result.info.task_ended_at, | |
| 82 | + | .task_exit_code = result.info.task_exit_code, | |
| 83 | + | }); | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | return sessions; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | pub fn shellNeedsQuoting(arg: []const u8) bool { | |
| 91 | + | if (arg.len == 0) return true; | |
| 92 | + | for (arg) |ch| { | |
| 93 | + | switch (ch) { | |
| 94 | + | ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true, | |
| 95 | + | else => {}, | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | return false; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | pub fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 { | |
| 102 | + | // Always use single quotes (like Python's shlex.quote). Inside single | |
| 103 | + | // quotes nothing is special except ' itself, which we handle with the | |
| 104 | + | // '\'' trick (end quote, escaped literal quote, reopen quote). | |
| 105 | + | var len: usize = 2; | |
| 106 | + | for (arg) |ch| { | |
| 107 | + | len += if (ch == '\'') 4 else 1; | |
| 108 | + | } | |
| 109 | + | const buf = try alloc.alloc(u8, len); | |
| 110 | + | var i: usize = 0; | |
| 111 | + | buf[i] = '\''; | |
| 112 | + | i += 1; | |
| 113 | + | for (arg) |ch| { | |
| 114 | + | if (ch == '\'') { | |
| 115 | + | @memcpy(buf[i..][0..4], "'\\''"); | |
| 116 | + | i += 4; | |
| 117 | + | } else { | |
| 118 | + | buf[i] = ch; | |
| 119 | + | i += 1; | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | buf[i] = '\''; | |
| 123 | + | return buf; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | const DA1_QUERY = "\x1b[c"; | |
| 127 | + | const DA1_QUERY_EXPLICIT = "\x1b[0c"; | |
| 128 | + | const DA2_QUERY = "\x1b[>c"; | |
| 129 | + | const DA2_QUERY_EXPLICIT = "\x1b[>0c"; | |
| 130 | + | const DA1_RESPONSE = "\x1b[?62;22c"; | |
| 131 | + | const DA2_RESPONSE = "\x1b[>1;10;0c"; | |
| 132 | + | ||
| 133 | + | pub fn respondToDeviceAttributes(pty_fd: i32, data: []const u8) void { | |
| 134 | + | // Scan for DA queries in PTY output and respond on behalf of the terminal. | |
| 135 | + | // This handles the case where no client is attached (e.g. zmx run) | |
| 136 | + | // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered. | |
| 137 | + | // | |
| 138 | + | // DA1 query: ESC [ c or ESC [ 0 c | |
| 139 | + | // DA2 query: ESC [ > c or ESC [ > 0 c | |
| 140 | + | // DA1 response (from terminal): ESC [ ? ... c (has '?' after '[') | |
| 141 | + | // | |
| 142 | + | // We must NOT match DA responses (which contain '?') as queries. | |
| 143 | + | var i: usize = 0; | |
| 144 | + | while (i < data.len) { | |
| 145 | + | if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') { | |
| 146 | + | // Skip DA responses which have '?' after CSI | |
| 147 | + | if (i + 2 < data.len and data[i + 2] == '?') { | |
| 148 | + | i += 3; | |
| 149 | + | continue; | |
| 150 | + | } | |
| 151 | + | if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) { | |
| 152 | + | _ = posix.write(pty_fd, DA2_RESPONSE) catch {}; | |
| 153 | + | } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) { | |
| 154 | + | _ = posix.write(pty_fd, DA1_RESPONSE) catch {}; | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | i += 1; | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | fn matchSeq(data: []const u8, seq: []const u8) bool { | |
| 162 | + | if (data.len < seq.len) return false; | |
| 163 | + | return std.mem.eql(u8, data[0..seq.len], seq); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | pub fn findTaskExitMarker(output: []const u8) ?u8 { | |
| 167 | + | const marker = "ZMX_TASK_COMPLETED:"; | |
| 168 | + | ||
| 169 | + | // Search for marker in output | |
| 170 | + | if (std.mem.indexOf(u8, output, marker)) |idx| { | |
| 171 | + | const after_marker = output[idx + marker.len ..]; | |
| 172 | + | ||
| 173 | + | // Find the exit code number and newline | |
| 174 | + | var end_idx: usize = 0; | |
| 175 | + | while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') { | |
| 176 | + | end_idx += 1; | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | const exit_code_str = after_marker[0..end_idx]; | |
| 180 | + | ||
| 181 | + | // Parse exit code | |
| 182 | + | if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| { | |
| 183 | + | return exit_code; | |
| 184 | + | } else |_| { | |
| 185 | + | std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str}); | |
| 186 | + | return null; | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | return null; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Detects Kitty keyboard protocol escape sequence for Ctrl+\ | |
| 194 | + | /// 92 = backslash, 5 = ctrl modifier, :1 = key press event | |
| 195 | + | pub fn isKittyCtrlBackslash(buf: []const u8) bool { | |
| 196 | + | return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or | |
| 197 | + | std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 { | |
| 201 | + | var builder: std.Io.Writer.Allocating = .init(alloc); | |
| 202 | + | defer builder.deinit(); | |
| 203 | + | ||
| 204 | + | var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt); | |
| 205 | + | term_formatter.content = .{ .selection = null }; | |
| 206 | + | term_formatter.extra = .{ | |
| 207 | + | .palette = false, | |
| 208 | + | .modes = true, | |
| 209 | + | .scrolling_region = true, | |
| 210 | + | .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position | |
| 211 | + | .pwd = true, | |
| 212 | + | .keyboard = true, | |
| 213 | + | .screen = .all, | |
| 214 | + | }; | |
| 215 | + | ||
| 216 | + | term_formatter.format(&builder.writer) catch |err| { | |
| 217 | + | std.log.warn("failed to format terminal state err={s}", .{@errorName(err)}); | |
| 218 | + | return null; | |
| 219 | + | }; | |
| 220 | + | ||
| 221 | + | const output = builder.writer.buffered(); | |
| 222 | + | if (output.len == 0) return null; | |
| 223 | + | ||
| 224 | + | return alloc.dupe(u8, output) catch |err| { | |
| 225 | + | std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)}); | |
| 226 | + | return null; | |
| 227 | + | }; | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | pub const HistoryFormat = enum(u8) { | |
| 231 | + | plain = 0, | |
| 232 | + | vt = 1, | |
| 233 | + | html = 2, | |
| 234 | + | }; | |
| 235 | + | ||
| 236 | + | pub fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 { | |
| 237 | + | var builder: std.Io.Writer.Allocating = .init(alloc); | |
| 238 | + | defer builder.deinit(); | |
| 239 | + | ||
| 240 | + | const opts: ghostty_vt.formatter.Options = switch (format) { | |
| 241 | + | .plain => .plain, | |
| 242 | + | .vt => .vt, | |
| 243 | + | .html => .html, | |
| 244 | + | }; | |
| 245 | + | var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts); | |
| 246 | + | term_formatter.content = .{ .selection = null }; | |
| 247 | + | term_formatter.extra = switch (format) { | |
| 248 | + | .plain => .none, | |
| 249 | + | .vt => .{ | |
| 250 | + | .palette = false, | |
| 251 | + | .modes = true, | |
| 252 | + | .scrolling_region = true, | |
| 253 | + | .tabstops = false, | |
| 254 | + | .pwd = true, | |
| 255 | + | .keyboard = true, | |
| 256 | + | .screen = .all, | |
| 257 | + | }, | |
| 258 | + | .html => .styles, | |
| 259 | + | }; | |
| 260 | + | ||
| 261 | + | term_formatter.format(&builder.writer) catch |err| { | |
| 262 | + | std.log.warn("failed to format terminal err={s}", .{@errorName(err)}); | |
| 263 | + | return null; | |
| 264 | + | }; | |
| 265 | + | ||
| 266 | + | const output = builder.writer.buffered(); | |
| 267 | + | if (output.len == 0) return null; | |
| 268 | + | ||
| 269 | + | return alloc.dupe(u8, output) catch |err| { | |
| 270 | + | std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)}); | |
| 271 | + | return null; | |
| 272 | + | }; | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | pub fn detectShell() [:0]const u8 { | |
| 276 | + | return std.posix.getenv("SHELL") orelse "/bin/sh"; | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// Formats a session entry for list output (only the name when `short` is | |
| 280 | + | /// true), adding a prefix to indicate the current session, if there is one. | |
| 281 | + | pub fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool, current_session: ?[]const u8) !void { | |
| 282 | + | const current_arrow = "→"; | |
| 283 | + | const prefix = if (current_session) |current| | |
| 284 | + | if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else " " | |
| 285 | + | else | |
| 286 | + | ""; | |
| 287 | + | ||
| 288 | + | if (short) { | |
| 289 | + | if (session.is_error) return; | |
| 290 | + | try writer.print("{s}\n", .{session.name}); | |
| 291 | + | return; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | if (session.is_error) { | |
| 295 | + | try writer.print("{s}name={s}\terr={s}\tstatus=cleaning up\n", .{ | |
| 296 | + | prefix, | |
| 297 | + | session.name, | |
| 298 | + | session.error_name.?, | |
| 299 | + | }); | |
| 300 | + | return; | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{ | |
| 304 | + | prefix, | |
| 305 | + | session.name, | |
| 306 | + | session.pid.?, | |
| 307 | + | session.clients_len.?, | |
| 308 | + | session.created_at, | |
| 309 | + | }); | |
| 310 | + | if (session.cwd) |cwd| { | |
| 311 | + | try writer.print("\tstart_dir={s}", .{cwd}); | |
| 312 | + | } | |
| 313 | + | if (session.cmd) |cmd| { | |
| 314 | + | try writer.print("\tcmd={s}", .{cmd}); | |
| 315 | + | } | |
| 316 | + | if (session.task_ended_at) |ended_at| { | |
| 317 | + | if (ended_at > 0) { | |
| 318 | + | try writer.print("\tended={d}", .{ended_at}); | |
| 319 | + | ||
| 320 | + | if (session.task_exit_code) |exit_code| { | |
| 321 | + | try writer.print("\texit_code={d}", .{exit_code}); | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | try writer.print("\n", .{}); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | test "writeSessionLine formats output for current session and short output" { | |
| 329 | + | const Case = struct { | |
| 330 | + | session: SessionEntry, | |
| 331 | + | short: bool, | |
| 332 | + | current_session: ?[]const u8, | |
| 333 | + | expected: []const u8, | |
| 334 | + | }; | |
| 335 | + | ||
| 336 | + | const session = SessionEntry{ | |
| 337 | + | .name = "dev", | |
| 338 | + | .pid = 123, | |
| 339 | + | .clients_len = 2, | |
| 340 | + | .is_error = false, | |
| 341 | + | .error_name = null, | |
| 342 | + | .cmd = null, | |
| 343 | + | .cwd = null, | |
| 344 | + | .created_at = 0, | |
| 345 | + | .task_ended_at = null, | |
| 346 | + | .task_exit_code = null, | |
| 347 | + | }; | |
| 348 | + | ||
| 349 | + | const cases = [_]Case{ | |
| 350 | + | .{ | |
| 351 | + | .session = session, | |
| 352 | + | .short = false, | |
| 353 | + | .current_session = "dev", | |
| 354 | + | .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n", | |
| 355 | + | }, | |
| 356 | + | .{ | |
| 357 | + | .session = session, | |
| 358 | + | .short = false, | |
| 359 | + | .current_session = "other", | |
| 360 | + | .expected = " name=dev\tpid=123\tclients=2\tcreated=0\n", | |
| 361 | + | }, | |
| 362 | + | .{ | |
| 363 | + | .session = session, | |
| 364 | + | .short = false, | |
| 365 | + | .current_session = null, | |
| 366 | + | .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n", | |
| 367 | + | }, | |
| 368 | + | .{ | |
| 369 | + | .session = session, | |
| 370 | + | .short = true, | |
| 371 | + | .current_session = "dev", | |
| 372 | + | .expected = "dev\n", | |
| 373 | + | }, | |
| 374 | + | .{ | |
| 375 | + | .session = session, | |
| 376 | + | .short = true, | |
| 377 | + | .current_session = "other", | |
| 378 | + | .expected = "dev\n", | |
| 379 | + | }, | |
| 380 | + | .{ | |
| 381 | + | .session = session, | |
| 382 | + | .short = true, | |
| 383 | + | .current_session = null, | |
| 384 | + | .expected = "dev\n", | |
| 385 | + | }, | |
| 386 | + | }; | |
| 387 | + | ||
| 388 | + | for (cases) |case| { | |
| 389 | + | var builder: std.Io.Writer.Allocating = .init(std.testing.allocator); | |
| 390 | + | defer builder.deinit(); | |
| 391 | + | ||
| 392 | + | try writeSessionLine(&builder.writer, case.session, case.short, case.current_session); | |
| 393 | + | try std.testing.expectEqualStrings(case.expected, builder.writer.buffered()); | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | test "shellNeedsQuoting" { | |
| 398 | + | try std.testing.expect(shellNeedsQuoting("")); | |
| 399 | + | try std.testing.expect(shellNeedsQuoting("hello world")); | |
| 400 | + | try std.testing.expect(shellNeedsQuoting("hello!")); | |
| 401 | + | try std.testing.expect(shellNeedsQuoting("$PATH")); | |
| 402 | + | try std.testing.expect(shellNeedsQuoting("it's")); | |
| 403 | + | try std.testing.expect(shellNeedsQuoting("a|b")); | |
| 404 | + | try std.testing.expect(shellNeedsQuoting("a;b")); | |
| 405 | + | try std.testing.expect(!shellNeedsQuoting("hello")); | |
| 406 | + | try std.testing.expect(!shellNeedsQuoting("bash")); | |
| 407 | + | try std.testing.expect(!shellNeedsQuoting("-c")); | |
| 408 | + | try std.testing.expect(!shellNeedsQuoting("/usr/bin/env")); | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | test "shellQuote" { | |
| 412 | + | const alloc = std.testing.allocator; | |
| 413 | + | ||
| 414 | + | const empty = try shellQuote(alloc, ""); | |
| 415 | + | defer alloc.free(empty); | |
| 416 | + | try std.testing.expectEqualStrings("''", empty); | |
| 417 | + | ||
| 418 | + | const space = try shellQuote(alloc, "hello world"); | |
| 419 | + | defer alloc.free(space); | |
| 420 | + | try std.testing.expectEqualStrings("'hello world'", space); | |
| 421 | + | ||
| 422 | + | const bang = try shellQuote(alloc, "hello!"); | |
| 423 | + | defer alloc.free(bang); | |
| 424 | + | try std.testing.expectEqualStrings("'hello!'", bang); | |
| 425 | + | ||
| 426 | + | const dollar = try shellQuote(alloc, "$PATH"); | |
| 427 | + | defer alloc.free(dollar); | |
| 428 | + | try std.testing.expectEqualStrings("'$PATH'", dollar); | |
| 429 | + | ||
| 430 | + | const sq = try shellQuote(alloc, "it's"); | |
| 431 | + | defer alloc.free(sq); | |
| 432 | + | try std.testing.expectEqualStrings("'it'\\''s'", sq); | |
| 433 | + | ||
| 434 | + | const dq = try shellQuote(alloc, "say \"hi\""); | |
| 435 | + | defer alloc.free(dq); | |
| 436 | + | try std.testing.expectEqualStrings("'say \"hi\"'", dq); | |
| 437 | + | ||
| 438 | + | const both = try shellQuote(alloc, "it's \"cool\""); | |
| 439 | + | defer alloc.free(both); | |
| 440 | + | try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both); | |
| 441 | + | ||
| 442 | + | // just a single quote | |
| 443 | + | const lone_sq = try shellQuote(alloc, "'"); | |
| 444 | + | defer alloc.free(lone_sq); | |
| 445 | + | try std.testing.expectEqualStrings("''\\'''", lone_sq); | |
| 446 | + | ||
| 447 | + | // multiple consecutive single quotes | |
| 448 | + | const triple_sq = try shellQuote(alloc, "'''"); | |
| 449 | + | defer alloc.free(triple_sq); | |
| 450 | + | try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq); | |
| 451 | + | ||
| 452 | + | // backtick command substitution | |
| 453 | + | const backtick = try shellQuote(alloc, "`whoami`"); | |
| 454 | + | defer alloc.free(backtick); | |
| 455 | + | try std.testing.expectEqualStrings("'`whoami`'", backtick); | |
| 456 | + | ||
| 457 | + | // dollar command substitution | |
| 458 | + | const dollar_cmd = try shellQuote(alloc, "$(whoami)"); | |
| 459 | + | defer alloc.free(dollar_cmd); | |
| 460 | + | try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd); | |
| 461 | + | ||
| 462 | + | // glob | |
| 463 | + | const glob = try shellQuote(alloc, "*.txt"); | |
| 464 | + | defer alloc.free(glob); | |
| 465 | + | try std.testing.expectEqualStrings("'*.txt'", glob); | |
| 466 | + | ||
| 467 | + | // tilde | |
| 468 | + | const tilde = try shellQuote(alloc, "~/file"); | |
| 469 | + | defer alloc.free(tilde); | |
| 470 | + | try std.testing.expectEqualStrings("'~/file'", tilde); | |
| 471 | + | ||
| 472 | + | // trailing backslash | |
| 473 | + | const trailing_bs = try shellQuote(alloc, "path\\"); | |
| 474 | + | defer alloc.free(trailing_bs); | |
| 475 | + | try std.testing.expectEqualStrings("'path\\'", trailing_bs); | |
| 476 | + | ||
| 477 | + | // semicolon (command injection) | |
| 478 | + | const semi = try shellQuote(alloc, "; rm -rf /"); | |
| 479 | + | defer alloc.free(semi); | |
| 480 | + | try std.testing.expectEqualStrings("'; rm -rf /'", semi); | |
| 481 | + | ||
| 482 | + | // embedded newline | |
| 483 | + | const newline = try shellQuote(alloc, "line1\nline2"); | |
| 484 | + | defer alloc.free(newline); | |
| 485 | + | try std.testing.expectEqualStrings("'line1\nline2'", newline); | |
| 486 | + | ||
| 487 | + | // parentheses (subshell) | |
| 488 | + | const parens = try shellQuote(alloc, "(echo hi)"); | |
| 489 | + | defer alloc.free(parens); | |
| 490 | + | try std.testing.expectEqualStrings("'(echo hi)'", parens); | |
| 491 | + | ||
| 492 | + | // heredoc marker | |
| 493 | + | const heredoc = try shellQuote(alloc, "<<EOF"); | |
| 494 | + | defer alloc.free(heredoc); | |
| 495 | + | try std.testing.expectEqualStrings("'<<EOF'", heredoc); | |
| 496 | + | ||
| 497 | + | // no quoting needed -- plain word should still be quoted | |
| 498 | + | // (shellQuote is only called when shellNeedsQuoting returns true, | |
| 499 | + | // but verify it produces valid output anyway) | |
| 500 | + | const plain = try shellQuote(alloc, "hello"); | |
| 501 | + | defer alloc.free(plain); | |
| 502 | + | try std.testing.expectEqualStrings("'hello'", plain); | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | test "isKittyCtrlBackslash" { | |
| 506 | + | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u")); | |
| 507 | + | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u")); | |
| 508 | + | try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u")); | |
| 509 | + | try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u")); | |
| 510 | + | try std.testing.expect(!isKittyCtrlBackslash("garbage")); | |
| 511 | + | } |