diff --git a/Cargo.lock b/Cargo.lock index 2bf60dc..9626ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6461,6 +6461,7 @@ name = "pty" version = "1.24.0" dependencies = [ "rustix 0.38.44", + "windows 0.58.0", ] [[package]] @@ -9974,6 +9975,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -10042,6 +10053,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -10101,6 +10125,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -10123,6 +10158,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -10186,6 +10232,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -10204,6 +10259,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" diff --git a/crates/pty/Cargo.toml b/crates/pty/Cargo.toml index f8ac371..298ed3c 100644 --- a/crates/pty/Cargo.toml +++ b/crates/pty/Cargo.toml @@ -5,5 +5,15 @@ version.workspace = true edition.workspace = true license.workspace = true -[dependencies] +[target.'cfg(unix)'.dependencies] rustix = { version = "0.38", features = ["pty", "process", "termios", "fs", "event", "stdio"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_Pipes", + "Win32_System_Threading", +] } diff --git a/crates/pty/src/lib.rs b/crates/pty/src/lib.rs index 897d777..9047104 100644 --- a/crates/pty/src/lib.rs +++ b/crates/pty/src/lib.rs @@ -1,7 +1,9 @@ -//! Unix pseudo-terminal management for the Prompt terminal emulator. +//! Pseudo-terminal management for the Prompt terminal emulator. //! -//! Open a pty pair, spawn a shell (or any argv) on the slave, and drive the -//! master: read child output, write input, resize, kill/wait. +//! Open a pty, spawn a shell (or any argv) attached to it, and drive it: read +//! child output, write input, resize, kill/wait. Two backends implement one +//! [`Pty`] API — a Unix pty pair (`rustix`) and a Windows pseudoconsole +//! (ConPTY, via the `windows` crate). //! //! ```no_run //! let opts = pty::SpawnOptions::default(); // user's login shell @@ -9,14 +11,24 @@ //! session.resize(pty::Winsize::new(120, 40)).unwrap(); //! ``` -#![cfg(unix)] +mod spawn; +mod winsize; +#[cfg(unix)] mod session; -mod spawn; +#[cfg(unix)] mod unix; -mod winsize; -pub use session::Pty; +#[cfg(windows)] +mod windows; + pub use spawn::{default_env, default_shell, SpawnOptions}; -pub use unix::{open_pair, spawn_child, PtyPair}; pub use winsize::Winsize; + +#[cfg(unix)] +pub use session::Pty; +#[cfg(unix)] +pub use unix::{open_pair, spawn_child, PtyPair}; + +#[cfg(windows)] +pub use windows::Pty; diff --git a/crates/pty/src/spawn.rs b/crates/pty/src/spawn.rs index 744acb5..0c53d5e 100644 --- a/crates/pty/src/spawn.rs +++ b/crates/pty/src/spawn.rs @@ -20,6 +20,7 @@ pub struct SpawnOptions { } /// The user's shell from `$SHELL`, falling back to `/bin/zsh`. +#[cfg(unix)] pub fn default_shell() -> String { std::env::var("SHELL") .ok() @@ -27,6 +28,15 @@ pub fn default_shell() -> String { .unwrap_or_else(|| "/bin/zsh".to_string()) } +/// The command interpreter from `%COMSPEC%`, falling back to `cmd.exe`. +#[cfg(windows)] +pub fn default_shell() -> String { + std::env::var("COMSPEC") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "cmd.exe".to_string()) +} + /// Default environment overrides for terminal children. pub fn default_env() -> Vec<(String, String)> { vec![ diff --git a/crates/pty/src/windows/conpty.rs b/crates/pty/src/windows/conpty.rs new file mode 100644 index 0000000..7140cf0 --- /dev/null +++ b/crates/pty/src/windows/conpty.rs @@ -0,0 +1,82 @@ +//! The pseudoconsole guard and pipe wiring. +//! +//! [`create`] makes two anonymous pipes and an `HPCON`. ConPTY keeps the +//! child's stdin-read and stdout-write ends; the caller receives the opposite +//! ends — `input` to write to the child, `output` to read its bytes — plus a +//! [`Pcon`] guard that closes the pseudoconsole on drop. + +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + +use windows::Win32::Foundation::HANDLE; +use windows::Win32::System::Console::{ + ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON, +}; +use windows::Win32::System::Pipes::CreatePipe; + +use crate::winsize::Winsize; + +/// Owns an `HPCON`, closing it on drop. +pub struct Pcon(HPCON); + +impl Pcon { + /// The raw handle, for the process-creation attribute list. + pub fn handle(&self) -> HPCON { + self.0 + } + + /// Resize the pseudoconsole; the child sees a console-resize event. + pub fn resize(&self, size: Winsize) -> io::Result<()> { + unsafe { ResizePseudoConsole(self.0, coord(size)) }.map_err(io::Error::other) + } +} + +impl Drop for Pcon { + fn drop(&mut self) { + unsafe { ClosePseudoConsole(self.0) }; + } +} + +/// Create a pseudoconsole sized to `size`. Returns the guard plus the input +/// (write) and output (read) pipe ends we keep. +pub fn create(size: Winsize) -> io::Result<(Pcon, OwnedHandle, OwnedHandle)> { + let (in_read, in_write) = pipe()?; + let (out_read, out_write) = pipe()?; + + let handle = unsafe { + CreatePseudoConsole(coord(size), to_handle(&in_read), to_handle(&out_write), 0) + } + .map_err(io::Error::other)?; + + // ConPTY duplicates the ends it needs; drop our copies of them. + drop(in_read); + drop(out_write); + + Ok((Pcon(handle), in_write, out_read)) +} + +/// A pseudoconsole grid size as a console `COORD` (columns = X, rows = Y). +fn coord(size: Winsize) -> COORD { + COORD { + X: size.cols as i16, + Y: size.rows as i16, + } +} + +/// Create an anonymous pipe, returning `(read, write)` owned ends. +fn pipe() -> io::Result<(OwnedHandle, OwnedHandle)> { + let mut read = HANDLE::default(); + let mut write = HANDLE::default(); + unsafe { CreatePipe(&mut read, &mut write, None, 0) }.map_err(io::Error::other)?; + Ok(unsafe { (own(read), own(write)) }) +} + +/// Borrow an owned handle as a Win32 `HANDLE`. +fn to_handle(h: &OwnedHandle) -> HANDLE { + HANDLE(h.as_raw_handle()) +} + +/// Take ownership of a raw Win32 `HANDLE` as an `OwnedHandle`. +unsafe fn own(h: HANDLE) -> OwnedHandle { + OwnedHandle::from_raw_handle(h.0) +} diff --git a/crates/pty/src/windows/mod.rs b/crates/pty/src/windows/mod.rs new file mode 100644 index 0000000..b7a02d9 --- /dev/null +++ b/crates/pty/src/windows/mod.rs @@ -0,0 +1,95 @@ +//! Windows pseudoconsole (ConPTY) backend. +//! +//! Mirrors the Unix [`crate::session::Pty`] API: open a pseudoconsole, spawn a +//! child attached to it, and drive it — read output, write input, resize, +//! kill/wait. + +mod conpty; +mod spawn; + +use std::fs::File; +use std::io::{self, Read, Write}; +use std::process::ExitStatus; + +use crate::spawn::SpawnOptions; +use crate::winsize::Winsize; + +use conpty::Pcon; +use spawn::Child; + +/// A child attached to a pseudoconsole. Owns the console guard, the two pipe +/// ends (as `File`s), and the child handle. +pub struct Pty { + // Field order is drop order: close the pipes, then the pseudoconsole, then + // the child handle. + input: File, + output: File, + pcon: Pcon, + child: Child, +} + +impl Pty { + /// Open a pseudoconsole sized from `opts`, then spawn the child on it. + pub fn spawn(opts: &SpawnOptions) -> io::Result { + let (pcon, input, output) = conpty::create(opts.winsize)?; + let child = spawn::spawn_child(opts, pcon.handle())?; + Ok(Self { + input: File::from(input), + output: File::from(output), + pcon, + child, + }) + } + + /// Write bytes to the child's input. Blocking. + pub fn write(&self, buf: &[u8]) -> io::Result { + (&self.input).write(buf) + } + + /// Read bytes of child output. Blocking; returns `Ok(0)` at EOF. + pub fn read(&self, buf: &mut [u8]) -> io::Result { + (&self.output).read(buf) + } + + /// A `File` over a duplicate of the output pipe, for a reader thread. + pub fn try_clone_reader(&self) -> io::Result { + self.output.try_clone() + } + + /// A `File` over a duplicate of the input pipe, for a writer. + pub fn try_clone_writer(&self) -> io::Result { + self.input.try_clone() + } + + /// Resize the pseudoconsole. The child sees a console-resize event. + pub fn resize(&self, size: Winsize) -> io::Result<()> { + self.pcon.resize(size) + } + + /// OS pid of the child. + pub fn child_pid(&self) -> u32 { + self.child.pid() + } + + /// Whether a foreground job other than the shell is running. ConPTY exposes + /// no controlling-terminal foreground-group query, so this is always + /// `false` on Windows (callers treat it as "not busy"). + pub fn foreground_running(&self) -> bool { + false + } + + /// Force-terminate the child. + pub fn kill(&mut self) -> io::Result<()> { + self.child.kill() + } + + /// Wait for the child to exit. + pub fn wait(&mut self) -> io::Result { + self.child.wait() + } + + /// Non-blocking check for child exit. + pub fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } +} diff --git a/crates/pty/src/windows/spawn.rs b/crates/pty/src/windows/spawn.rs new file mode 100644 index 0000000..014d092 --- /dev/null +++ b/crates/pty/src/windows/spawn.rs @@ -0,0 +1,207 @@ +//! Spawn a child process attached to a pseudoconsole via `CreateProcessW`. +//! +//! The `HPCON` is passed through a process-thread attribute list +//! (`PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`) with `EXTENDED_STARTUPINFO_PRESENT`; +//! handle inheritance is off, since the console handles travel via the +//! attribute rather than the handle table. + +use std::ffi::{c_void, OsStr}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::process::ExitStatus; +use std::ptr::null_mut; + +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0}; +use windows::Win32::System::Console::HPCON; +use windows::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, + InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + STARTUPINFOEXW, +}; + +use crate::spawn::SpawnOptions; + +/// A spawned child: its process and main-thread handles plus its pid. +pub struct Child { + process: OwnedHandle, + pid: u32, + // Kept only to close on drop; the main thread handle is otherwise unused. + _thread: OwnedHandle, +} + +impl Child { + /// OS pid of the child. + pub fn pid(&self) -> u32 { + self.pid + } + + /// Force-terminate the child. + pub fn kill(&mut self) -> io::Result<()> { + unsafe { TerminateProcess(self.handle(), 1) }.map_err(io::Error::other) + } + + /// Block until the child exits, returning its exit status. + pub fn wait(&mut self) -> io::Result { + unsafe { WaitForSingleObject(self.handle(), INFINITE) }; + self.exit_status() + } + + /// Non-blocking exit check; `None` while the child is still running. + pub fn try_wait(&mut self) -> io::Result> { + if unsafe { WaitForSingleObject(self.handle(), 0) } == WAIT_OBJECT_0 { + Ok(Some(self.exit_status()?)) + } else { + Ok(None) + } + } + + fn handle(&self) -> HANDLE { + HANDLE(self.process.as_raw_handle()) + } + + fn exit_status(&self) -> io::Result { + use std::os::windows::process::ExitStatusExt; + let mut code = 0u32; + unsafe { GetExitCodeProcess(self.handle(), &mut code) }.map_err(io::Error::other)?; + Ok(ExitStatus::from_raw(code)) + } +} + +/// Spawn `opts.argv` attached to the pseudoconsole `pcon`. +pub fn spawn_child(opts: &SpawnOptions, pcon: HPCON) -> io::Result { + let mut cmdline = wide(&command_line(&opts.argv)); + let cwd = opts.cwd.as_ref().map(|p| wide(p.as_os_str())); + let mut env = env_block(&opts.env); + + // Attribute list holding just the pseudoconsole. The first call reports the + // required buffer size via an (ignored) failure. + let mut size = 0usize; + unsafe { + let _ = InitializeProcThreadAttributeList( + LPPROC_THREAD_ATTRIBUTE_LIST(null_mut()), + 1, + 0, + &mut size, + ); + } + let mut buf = vec![0u8; size]; + let attrs = LPPROC_THREAD_ATTRIBUTE_LIST(buf.as_mut_ptr().cast()); + unsafe { InitializeProcThreadAttributeList(attrs, 1, 0, &mut size) } + .map_err(io::Error::other)?; + unsafe { + UpdateProcThreadAttribute( + attrs, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + Some(pcon.0 as *const c_void), + std::mem::size_of::(), + None, + None, + ) + } + .map_err(io::Error::other)?; + + let mut si = STARTUPINFOEXW::default(); + si.StartupInfo.cb = std::mem::size_of::() as u32; + si.lpAttributeList = attrs; + + let mut pi = PROCESS_INFORMATION::default(); + + let cwd_ptr = cwd + .as_ref() + .map(|w| PCWSTR(w.as_ptr())) + .unwrap_or(PCWSTR::null()); + + let result = unsafe { + CreateProcessW( + PCWSTR::null(), + PWSTR(cmdline.as_mut_ptr()), + None, + None, + false, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + Some(env.as_mut_ptr().cast()), + cwd_ptr, + &si.StartupInfo, + &mut pi, + ) + }; + + unsafe { DeleteProcThreadAttributeList(attrs) }; + result.map_err(io::Error::other)?; + + Ok(Child { + process: unsafe { OwnedHandle::from_raw_handle(pi.hProcess.0) }, + _thread: unsafe { OwnedHandle::from_raw_handle(pi.hThread.0) }, + pid: pi.dwProcessId, + }) +} + +/// A UTF-16, NUL-terminated copy of `s`, ready for a `PWSTR`/`PCWSTR`. +fn wide(s: &OsStr) -> Vec { + s.encode_wide().chain(std::iter::once(0)).collect() +} + +/// Join argv into a single command line, quoting arguments that need it per the +/// `CommandLineToArgvW` rules. +fn command_line(argv: &[String]) -> std::ffi::OsString { + let mut line = String::new(); + for (i, arg) in argv.iter().enumerate() { + if i > 0 { + line.push(' '); + } + line.push_str("e(arg)); + } + line.into() +} + +/// Quote a single argument for the Windows command-line convention. +fn quote(arg: &str) -> String { + if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\u{b}', '"']) { + return arg.to_string(); + } + let mut out = String::from("\""); + let mut backslashes = 0usize; + for c in arg.chars() { + match c { + '\\' => backslashes += 1, + '"' => { + // Escape the run of backslashes preceding the quote, then the quote. + out.extend(std::iter::repeat('\\').take(backslashes * 2 + 1)); + out.push('"'); + backslashes = 0; + } + _ => { + out.extend(std::iter::repeat('\\').take(backslashes)); + backslashes = 0; + out.push(c); + } + } + } + out.extend(std::iter::repeat('\\').take(backslashes * 2)); + out.push('"'); + out +} + +/// Build a `CREATE_UNICODE_ENVIRONMENT` block: the parent environment merged +/// with `overrides`, as `KEY=VALUE\0…\0\0` in UTF-16. +fn env_block(overrides: &[(String, String)]) -> Vec { + use std::collections::BTreeMap; + + let mut vars: BTreeMap = std::env::vars().collect(); + for (k, v) in overrides { + vars.insert(k.clone(), v.clone()); + } + + let mut block = Vec::new(); + for (k, v) in &vars { + block.extend(format!("{k}={v}").encode_utf16()); + block.push(0); + } + block.push(0); + block +} diff --git a/crates/pty/src/winsize.rs b/crates/pty/src/winsize.rs index c416fcd..5384fcf 100644 --- a/crates/pty/src/winsize.rs +++ b/crates/pty/src/winsize.rs @@ -36,6 +36,7 @@ impl Winsize { /// Convert to the kernel `struct winsize`. Pixel fields are the total /// window pixel size (grid * cell), saturating on overflow. + #[cfg(unix)] pub fn to_termios(self) -> rustix::termios::Winsize { rustix::termios::Winsize { ws_row: self.rows, diff --git a/crates/terminal/src/lib.rs b/crates/terminal/src/lib.rs index 6b9e2d6..907e8d1 100644 --- a/crates/terminal/src/lib.rs +++ b/crates/terminal/src/lib.rs @@ -4,8 +4,6 @@ //! a [`vt::Terminal`] on a dedicated reader thread, and reports [`Event`]s //! (wakeups, title changes, bell, exit) to the embedder over a channel. -#![cfg(unix)] - pub mod event; pub mod options; pub mod session;