diff --git a/Cargo.lock b/Cargo.lock index bc71799..0dc420a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,7 @@ dependencies = [ "calloop", "calloop-wayland-source", "pound", + "rustix", "smithay-client-toolkit", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 7a88bfc..7f74023 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ anyhow = "1.0.102" calloop = "0.14.4" calloop-wayland-source = "0.4.1" pound = "0.1.6" +rustix = { version = "1.1.4", features = ["pty", "process", "termios", "stdio", "fs"] } smithay-client-toolkit = "0.20.0" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/src/main.rs b/src/main.rs index a0be920..7634bed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ //! beer, a fast, software-rendered, Wayland-native terminal emulator. +mod pty; mod wayland; use std::process::ExitCode; diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 0000000..9047984 --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,112 @@ +//! Pseudo-terminal: open a master/slave pair and run the user's shell on it. + +use std::ffi::OsStr; +use std::io; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Child, Command, ExitStatus, Stdio}; + +use anyhow::Context; +use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt}; +use rustix::termios::{Winsize, tcsetwinsize}; + +/// A running child process attached to a PTY master. +#[derive(Debug)] +pub struct Pty { + master: OwnedFd, + child: Child, +} + +impl Pty { + /// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on + /// the slave end with `TERM=beer`. + pub fn spawn(cols: u16, rows: u16) -> anyhow::Result { + let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC) + .context("open pty master")?; + grantpt(&master).context("grantpt")?; + unlockpt(&master).context("unlockpt")?; + let slave = ioctl_tiocgptpeer(&master, OpenptFlags::RDWR | OpenptFlags::NOCTTY) + .context("open pty slave")?; + + set_winsize(&master, cols, rows)?; + + let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into()); + let argv0 = login_argv0(&shell); + + // Hand the slave to the child's stdio. try_clone gives O_CLOEXEC dups, so + // the parent's copies and the controlling-tty handle vanish at exec. + let ctty = slave.try_clone().context("dup slave")?; + let (stdin, stdout, stderr) = ( + slave.try_clone().context("dup slave")?, + slave.try_clone().context("dup slave")?, + slave, + ); + + let mut cmd = Command::new(&shell); + cmd.arg0(&argv0) + .env("TERM", "beer") + .env_remove("COLUMNS") + .env_remove("LINES") + .env_remove("TERMCAP") + .stdin(Stdio::from(stdin)) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + + // SAFETY: setsid and the TIOCSCTTY ioctl are async-signal-safe raw + // syscalls; ctty is a valid fd captured by move. We touch no parent + // heap state, satisfying pre_exec's contract. + unsafe { + cmd.pre_exec(move || { + rustix::process::setsid()?; + rustix::process::ioctl_tiocsctty(&ctty)?; + Ok(()) + }); + } + + let child = cmd.spawn().context("spawn shell")?; + Ok(Self { master, child }) + } + + /// The PTY master, for reading child output and writing input. + pub fn master(&self) -> &OwnedFd { + &self.master + } + + /// Reap the child if it has exited. + pub fn wait(&mut self) -> io::Result { + self.child.wait() + } +} + +fn set_winsize(master: &OwnedFd, cols: u16, rows: u16) -> anyhow::Result<()> { + let ws = Winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + tcsetwinsize(master.as_fd(), ws).context("set pty winsize") +} + +/// Login-shell argv[0] is the shell's basename with a leading '-'. +fn login_argv0(shell: &OsStr) -> std::ffi::OsString { + let name = Path::new(shell) + .file_name() + .unwrap_or_else(|| OsStr::new("sh")); + let mut argv0 = std::ffi::OsString::from("-"); + argv0.push(name); + argv0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn argv0_is_dash_prefixed_basename() { + assert_eq!(login_argv0(OsStr::new("/usr/bin/bash")), "-bash"); + assert_eq!(login_argv0(OsStr::new("zsh")), "-zsh"); + assert_eq!(login_argv0(OsStr::new("")), "-sh"); + } +} diff --git a/src/wayland.rs b/src/wayland.rs index 29f681e..cd4ad5e 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -6,8 +6,11 @@ use std::time::Duration; use anyhow::Context; -use calloop::EventLoop; +use calloop::generic::Generic; +use calloop::{EventLoop, Interest, Mode, PostAction}; use calloop_wayland_source::WaylandSource; + +use crate::pty::Pty; use smithay_client_toolkit::{ compositor::{CompositorHandler, CompositorState}, delegate_compositor, delegate_output, delegate_registry, delegate_seat, delegate_shm, @@ -36,6 +39,9 @@ const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600; /// Background fill, 0xAARRGGBB. Foot-ish dark grey. const BG: u32 = 0xFF18_1818; +/// Terminal size handed to the shell until cell geometry drives it. +const COLS: u16 = 80; +const ROWS: u16 = 24; /// Run a single window until it is closed. pub fn run() -> anyhow::Result<()> { @@ -65,6 +71,38 @@ pub fn run() -> anyhow::Result<()> { let pool = SlotPool::new(DEFAULT_W as usize * DEFAULT_H as usize * 4, &shm) .context("create shm slot pool")?; + let pty = Pty::spawn(COLS, ROWS).context("spawn shell on pty")?; + + // Read child output off a clone of the master; the original stays in `pty` + // for writes and resizing. + let read_fd = pty.master().try_clone().context("clone pty master")?; + event_loop + .handle() + .insert_source( + Generic::new(read_fd, Interest::READ, Mode::Level), + |_, fd, app: &mut App| { + let mut buf = [0u8; 4096]; + match rustix::io::read(&*fd, &mut buf) { + Ok(0) => { + app.child_exited(); + Ok(PostAction::Remove) + } + Ok(n) => { + tracing::debug!("pty -> {n} bytes: {:02x?}", &buf[..n]); + Ok(PostAction::Continue) + } + Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => { + Ok(PostAction::Continue) + } + Err(_) => { + app.child_exited(); + Ok(PostAction::Remove) + } + } + }, + ) + .map_err(|e| anyhow::anyhow!("register pty in event loop: {e}"))?; + let mut app = App { registry_state: RegistryState::new(&globals), output_state: OutputState::new(&globals, &qh), @@ -72,6 +110,7 @@ pub fn run() -> anyhow::Result<()> { shm, pool, window, + pty, width: DEFAULT_W, height: DEFAULT_H, configured: false, @@ -95,6 +134,7 @@ struct App { shm: Shm, pool: SlotPool, window: Window, + pty: Pty, width: u32, height: u32, configured: bool, @@ -102,6 +142,15 @@ struct App { } impl App { + /// The child shell has gone away; reap it and tear the window down. + fn child_exited(&mut self) { + match self.pty.wait() { + Ok(status) => tracing::info!("shell exited: {status}"), + Err(err) => tracing::warn!("reap shell: {err}"), + } + self.exit = true; + } + /// Fill the surface with the background colour and present it. fn draw(&mut self) { let (w, h) = (self.width, self.height);