//! PTY wrapper using `portable-pty` for cross-platform pseudoterminal support. use std::collections::HashMap; use std::io::{Read, Write}; use std::path::PathBuf; use anyhow::{Context, Result}; use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; /// Configuration for spawning a PTY session. #[derive(Debug, Clone)] pub struct PtyConfig { /// Command and arguments to run. pub command: Vec, /// Terminal width in columns. pub cols: u16, /// Terminal height in rows. pub rows: u16, /// Working directory. pub cwd: Option, /// Additional environment variables. pub env: HashMap, } /// Handle to a running PTY session. pub struct PtyHandle { master: Box, child: Box, reader: Box, writer: Box, } /// Resize-capable master half of a dismantled [`PtyHandle`]. /// /// portable-pty's unix master is not `Sync` (interior `RefCell`), so it sits /// behind a mutex that is only held for the synchronous resize ioctl. pub struct PtyMaster { master: std::sync::Mutex>, } impl PtyMaster { /// Resize the PTY (TIOCSWINSZ; the kernel delivers SIGWINCH to the child). pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { self.master .lock() .unwrap() .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .context("failed to resize PTY") } } /// Child-process half of a dismantled [`PtyHandle`]. pub struct PtyChild { child: Box, } impl PtyChild { /// Check if the child process is still alive. pub fn is_alive(&mut self) -> bool { self.child.try_wait().ok().flatten().is_none() } /// Get the child process ID. pub fn pid(&self) -> Option { self.child.process_id() } /// Wait for the child to exit and return the exit code. pub fn wait(&mut self) -> Result { let status = self.child.wait().context("failed to wait for child")?; Ok(status.exit_code()) } /// Kill the child process. pub fn kill(&mut self) -> Result<()> { self.child.kill().context("failed to kill child process") } } impl PtyHandle { /// Spawn a new process in a PTY. pub fn spawn(config: &PtyConfig) -> Result { let pty_system = native_pty_system(); let pty_size = PtySize { rows: config.rows, cols: config.cols, pixel_width: 0, pixel_height: 0, }; let pair = pty_system.openpty(pty_size).context("failed to open PTY")?; let mut cmd = CommandBuilder::new(&config.command[0]); if config.command.len() > 1 { cmd.args(&config.command[1..]); } if let Some(ref cwd) = config.cwd { cmd.cwd(cwd); } for (key, value) in &config.env { cmd.env(key, value); } // Set TERM for proper terminal detection. cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); let child = pair .slave .spawn_command(cmd) .context("failed to spawn command in PTY")?; let reader = pair .master .try_clone_reader() .context("failed to clone PTY reader")?; let writer = pair .master .take_writer() .context("failed to take PTY writer")?; Ok(Self { master: pair.master, child, reader, writer, }) } /// Dismantle into the master half (kept for resize), the child half /// (moved into a waiter task), and the reader/writer streams. pub fn into_parts( self, ) -> ( PtyMaster, PtyChild, Box, Box, ) { ( PtyMaster { master: std::sync::Mutex::new(self.master), }, PtyChild { child: self.child }, self.reader, self.writer, ) } }