diff --git a/README.md b/README.md index f9d4e45..d7ed9d9 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ in principle, but a terminal grid is small. ## What works -- Real shell on a real PTY, with a controlling terminal so job control, +- Your login shell (from the passwd database, not `$SHELL`) on a real PTY, + started as a login shell, with a controlling terminal so job control, Ctrl-C and SIGWINCH behave - Full SGR rendering: 16/256/true color, bold, italic, underline, strikethrough, inverse, and the bright-on-bold convention @@ -120,9 +121,6 @@ Sway** and screenshots it. This keeps UI checks entirely out of your real Wayland session — nothing appears on screen, and it works while the session is locked. -Screenshots use `$SHELL` like the real app does; set -`VTABS_SHOT_SHELL=/bin/sh` for output uncluttered by your shell's rc files. - One caveat when driving it: `wtype` loses the first keystroke of every invocation while the compositor adopts its freshly uploaded keymap, so scripted input should begin with a throwaway key. That is a quirk of the injection tool, diff --git a/shot.sh b/shot.sh index 7028bfd..ef1caee 100755 --- a/shot.sh +++ b/shot.sh @@ -40,10 +40,6 @@ export WLR_RENDERER=pixman export LIBGL_ALWAYS_SOFTWARE=1 export GSK_RENDERER=cairo export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" -# The nix dev shell points SHELL at a minimal bash (no readline, no -# programmable completion), which makes screenshots look broken for reasons -# that have nothing to do with vtabs. Use the system shell instead. -export SHELL="${VTABS_SHOT_SHELL:-/bin/bash}" export SWAYSOCK="$RUNDIR/sway.sock" # Don't let the app fall back to the real session. unset DISPLAY diff --git a/src/Pty.zig b/src/Pty.zig index 9de14ac..b4de210 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -41,6 +41,14 @@ const c = struct { extern "c" fn _exit(code: c_int) noreturn; extern "c" fn kill(pid: pid_t, sig: c_int) c_int; extern "c" fn waitpid(pid: pid_t, status: ?*c_int, options: c_int) pid_t; + extern "c" fn getuid() std.c.uid_t; + extern "c" fn getpwuid_r( + uid: std.c.uid_t, + pwd: *std.c.passwd, + buf: [*]u8, + buflen: usize, + result: *?*std.c.passwd, + ) c_int; }; const O_RDWR = 0x0002; @@ -69,11 +77,18 @@ pub const Error = error{ ForkFailed, }; -/// Open a PTY pair and fork `argv` onto the slave side. The child gets its +/// Open a PTY pair and fork `path` onto the slave side. The child gets its /// own session with the slave as controlling terminal, so job control and /// signals (Ctrl-C, SIGWINCH) work the way a shell expects. +/// +/// `path` is the executable to run and `argv` is the argument vector it +/// sees, including argv[0]. These are separate because a login shell is +/// started by running e.g. /usr/bin/zsh with an argv[0] of "-zsh"; folding +/// them together would pass "-zsh" as an ordinary argument instead, which +/// shells either misparse or reject outright. pub fn create( alloc: std.mem.Allocator, + path: [:0]const u8, argv: []const [:0]const u8, size: Winsize, ) !Pty { @@ -105,7 +120,7 @@ pub fn create( const pid = c.fork(); if (pid < 0) return Error.ForkFailed; if (pid == 0) { - childExec(master, slave_path_z, argv_z, envp_z); + childExec(master, slave_path_z, path, argv_z, envp_z); // childExec only returns on failure, and a forked child has no // sensible way to report that back to us. c._exit(127); @@ -118,6 +133,7 @@ pub fn create( fn childExec( master: fd_t, slave_path: [:0]const u8, + path: [:0]const u8, argv: [:null]const ?[*:0]const u8, envp: [:null]const ?[*:0]const u8, ) void { @@ -139,7 +155,31 @@ fn childExec( if (c.dup2(slave, 2) < 0) return; if (slave > 2) _ = c.close(slave); - _ = c.execvpe(argv[0].?, argv.ptr, envp.ptr); + _ = c.execvpe(path.ptr, argv.ptr, envp.ptr); +} + +/// The login shell recorded for the current user in the passwd database, +/// copied into `buf`. Returns null if there is no entry or it names no shell. +/// +/// This is the shell the user actually configured (what `chsh` sets). Unlike +/// $SHELL it cannot be overwritten by whatever environment the terminal was +/// launched from. +pub fn loginShell(buf: []u8) ?[]const u8 { + // getpwuid_r writes the strings it returns into a caller-provided scratch + // buffer, kept separate from `buf` so the result survives the copy out. + var scratch: [4096]u8 = undefined; + var pw: std.c.passwd = undefined; + var result: ?*std.c.passwd = null; + + if (c.getpwuid_r(c.getuid(), &pw, &scratch, scratch.len, &result) != 0) return null; + const entry = result orelse return null; + const shell = entry.shell orelse return null; + + const span = std.mem.span(shell); + if (span.len == 0 or span.len > buf.len) return null; + + @memcpy(buf[0..span.len], span); + return buf[0..span.len]; } /// Copy the current environment, forcing the variables that describe what diff --git a/src/Session.zig b/src/Session.zig index c8aed18..7e0fd6e 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -96,13 +96,14 @@ pub fn create( const shell = try defaultShell(alloc); defer alloc.free(shell); - // A leading '-' in argv[0] tells the shell to start as a login shell. + // A leading '-' in argv[0] tells the shell to start as a login shell, + // so it reads the user's profile. const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{ std.fs.path.basename(shell), }, 0); defer alloc.free(argv0); - self.pty = try .create(alloc, &.{ shell, argv0 }, .{ + self.pty = try .create(alloc, shell, &.{argv0}, .{ .ws_row = rows, .ws_col = cols, }); @@ -126,12 +127,23 @@ pub fn destroy(self: *Session) void { self.alloc.destroy(self); } -/// Resolve the user's shell, falling back to something that always exists. +/// Resolve the shell to run, most authoritative source first. +/// +/// The passwd entry comes first deliberately. $SHELL is only a description of +/// the shell that happened to launch us, and plenty of environments overwrite +/// it: `nix develop` replaces it with its own bash, and Flatpak pins it to +/// /bin/sh. The passwd entry is what the user actually configured, so it is +/// the better answer to "the user's default shell". $SHELL remains a fallback +/// for systems with no usable passwd entry, such as minimal containers. fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 { + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (Pty.loginShell(&buf)) |shell| return alloc.dupeZ(u8, shell); + if (std.c.getenv("SHELL")) |sh| { const span = std.mem.span(sh); if (span.len > 0) return alloc.dupeZ(u8, span); } + return alloc.dupeZ(u8, "/bin/sh"); }