diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e15ef0..b086f8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,11 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccfe9eb..00e6094 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: - target: aarch64-apple-darwin os: macos-latest artifact: vibetty-macos-arm64 + - target: x86_64-pc-windows-msvc + os: windows-latest + artifact: vibetty-windows-x64.exe runs-on: ${{ matrix.os }} steps: diff --git a/Cargo.lock b/Cargo.lock index 9d8d6b2..5912288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -888,6 +888,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dunce" version = "1.0.5" @@ -1945,9 +1951,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -2052,7 +2058,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -2209,6 +2215,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2560,6 +2578,26 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty-psmux" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be878e62281922d14cb797f41b539fb1eadf1cb58c5d985a04a679e230442932" +dependencies = [ + "anyhow", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.31.3", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2631,16 +2669,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "pty-process" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266" -dependencies = [ - "rustix 1.1.4", - "tokio", -] - [[package]] name = "pxfm" version = "0.1.28" @@ -3396,6 +3424,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3418,6 +3457,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3700,7 +3755,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -4226,7 +4281,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vibetty" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "axum", @@ -4242,7 +4297,7 @@ dependencies = [ "hanconv", "image", "log", - "pty-process", + "portable-pty-psmux", "ratatui", "reqwest", "reqwest-websocket", @@ -4923,6 +4978,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 065a90a..1fbfa16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vibetty" -version = "0.3.2" +version = "0.3.3" edition = "2024" @@ -40,7 +40,7 @@ hanconv = "0.5.0" bytes = "1.11.1" uuid = { version = "1.21.0", features = ["v4"] } -pty-process = { version = "0.5.3", features = ["async"] } +portable-pty = { version = "0.9.3", package = "portable-pty-psmux" } strip-ansi-escapes = "0.2.1" # TUI diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 7197a14..4c3472f 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,18 +1,28 @@ -use pty_process::{Command, Pty, Size}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - process::Child, -}; +use portable_pty::{Child, MasterPty, PtySize}; +use tokio::sync::{mpsc, oneshot}; pub mod pty; -pub type PtyCommand = Command; -pub type PtySize = Size; - +/// A write request handed off to the blocking writer thread: the bytes to +/// write plus a one-shot channel used to acknowledge the result. +pub(crate) type WriteMsg = (Vec, oneshot::Sender>); + +/// A terminal session backed by a cross-platform PTY. +/// +/// `portable-pty` exposes blocking `std::io` handles rather than async ones, +/// so the actual reads and writes happen on dedicated blocking threads and are +/// bridged to async via tokio channels: +/// +/// - a reader thread owns the PTY reader and pushes output chunks onto +/// `read_rx`; `read()` returns them verbatim as owned `Vec`. +/// - a writer thread owns the PTY writer and drains `write_rx`; `write_all()` +/// hands it bytes plus a one-shot ack and awaits the result. pub struct EchokitChild { uuid: uuid::Uuid, - pty: Pty, - child: Child, + master: Box, + child: Option>, + read_rx: mpsc::Receiver>, + write_tx: mpsc::Sender, } #[allow(unused)] @@ -22,8 +32,15 @@ impl EchokitChild { } pub async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { - self.pty.write_all(buf).await?; - self.pty.flush().await + let (ack_tx, ack_rx) = oneshot::channel(); + self.write_tx + .send((buf.to_vec(), ack_tx)) + .await + .map_err(|_| std::io::Error::from(std::io::ErrorKind::BrokenPipe))?; + match ack_rx.await { + Ok(res) => res, + Err(_) => Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)), + } } pub async fn send_key_iter>(&mut self, keys: &[S]) -> std::io::Result<()> { @@ -70,69 +87,87 @@ impl EchokitChild { self.write_all(b"\r").await } - pub async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { - self.pty.read(buffer).await + /// Pull the next chunk of PTY output off the channel. + /// + /// Returns the chunk verbatim (whatever the reader thread read in one go). + /// An empty `Vec` signals EOF / channel closed (the child exited or the + /// reader thread stopped). Because chunks are returned owned and whole, + /// there is no caller buffer to underfill — no partial-read buffering. + pub async fn read(&mut self) -> std::io::Result> { + Ok(self.read_rx.recv().await.unwrap_or_default()) } pub async fn read_string(&mut self) -> std::io::Result { - let mut buffer = [0u8; 1024]; let mut string_buffer = Vec::with_capacity(512); loop { - let n = self.pty.read(&mut buffer).await?; - if n == 0 { + let chunk = self.read().await?; + if chunk.is_empty() { break; } - string_buffer.extend_from_slice(&buffer[..n]); + string_buffer.extend_from_slice(&chunk); - let s = str::from_utf8(&string_buffer); - if let Ok(s) = s { - return Ok(s.to_string()); + if str::from_utf8(&string_buffer).is_ok() { + return Ok(String::from_utf8_lossy(&string_buffer).into_owned()); } } - Ok(String::from_utf8_lossy(&string_buffer).to_string()) + Ok(String::from_utf8_lossy(&string_buffer).into_owned()) } - pub async fn wait(&mut self) -> std::io::Result { - self.child.wait().await + pub async fn wait(&mut self) -> anyhow::Result { + let mut child = self + .child + .take() + .ok_or_else(|| anyhow::anyhow!("child process handle already consumed"))?; + Ok(tokio::task::spawn_blocking(move || child.wait()).await??) } - pub async fn kill(&mut self) -> std::io::Result<()> { - self.child.kill().await + pub async fn kill(&mut self) -> anyhow::Result<()> { + let child = self + .child + .as_ref() + .ok_or_else(|| anyhow::anyhow!("child process handle already consumed"))?; + let mut killer = child.clone_killer(); + tokio::task::spawn_blocking(move || killer.kill()).await?; + Ok(()) } pub fn resize(&mut self, rows: u16, cols: u16) -> std::io::Result<()> { - self.pty - .resize(PtySize::new(rows, cols)) + self.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) .map_err(std::io::Error::other) } pub async fn read_pty_output(&mut self) -> std::io::Result { - let mut buffer = [0u8; 1024]; let mut string_buffer = Vec::with_capacity(512); - let n = self.pty.read(&mut buffer).await?; - if n == 0 { + let chunk = self.read().await?; + if chunk.is_empty() { return Ok(String::new()); } - string_buffer.extend_from_slice(&buffer[..n]); + string_buffer.extend_from_slice(&chunk); - // Drain remaining buffered data + // Drain more chunks until we have a complete UTF-8 sequence + // (a multi-byte char may be split across chunks). loop { - let s = str::from_utf8(&string_buffer); - if s.is_ok() { + if str::from_utf8(&string_buffer).is_ok() { break; } - let n = self.pty.read(&mut buffer).await?; - if n == 0 { + let chunk = self.read().await?; + if chunk.is_empty() { break; } - string_buffer.extend_from_slice(&buffer[..n]); + string_buffer.extend_from_slice(&chunk); } - Ok(String::from_utf8_lossy(&string_buffer).to_string()) + Ok(String::from_utf8_lossy(&string_buffer).into_owned()) } } diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index cb5154d..6153875 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -1,4 +1,8 @@ -use super::{EchokitChild, PtyCommand, PtySize}; +use std::io::{Read, Write}; + +use super::{EchokitChild, WriteMsg}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use tokio::sync::{mpsc, oneshot}; pub async fn new_with_command>( shell: &str, @@ -6,37 +10,110 @@ pub async fn new_with_command>( env: &[(S, S)], size: (u16, u16), current_dir: Option, -) -> pty_process::Result { +) -> anyhow::Result { let (row, col) = size; - let (pty, pts) = pty_process::open()?; - pty.resize(PtySize::new(row, col))?; + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows: row, + cols: col, + pixel_width: 0, + pixel_height: 0, + })?; - let mut cmd = PtyCommand::new(shell); + let mut cmd = CommandBuilder::new(shell); + for arg in args.iter() { + cmd.arg(arg.as_ref()); + } + // Explicitly inherit vibetty's environment instead of relying on + // portable-pty's implicit snapshot taken inside CommandBuilder::new + // (get_base_env). portable-pty already captures std::env::vars_os there, + // but — like its cwd default — that's implicit behavior we'd rather not + // depend on, so we set it ourselves. + for (key, value) in std::env::vars_os() { + cmd.env(key, value); + } - cmd = cmd - .args(args.iter().map(|arg| arg.as_ref())) - .env("TERM", "xterm-256color") - .env("COLUMNS", col.to_string()) - .env("LINES", row.to_string()) - .env("FORCE_COLOR", "1") - .env("COLORTERM", "truecolor") - .env("PYTHONUNBUFFERED", "1"); + cmd.env("TERM", "xterm-256color"); + cmd.env("COLUMNS", col.to_string()); + cmd.env("LINES", row.to_string()); + cmd.env("FORCE_COLOR", "1"); + cmd.env("COLORTERM", "truecolor"); + cmd.env("PYTHONUNBUFFERED", "1"); for (key, value) in env { - cmd = cmd.env(key.as_ref(), value.as_ref()); + cmd.env(key.as_ref(), value.as_ref()); } - if let Some(current_dir) = current_dir { - cmd = cmd.current_dir(current_dir); + // portable-pty defaults the working directory to $HOME when cwd is unset + // (see CommandBuilder::as_command), whereas pty-process inherited the + // parent's cwd. Fall back to vibetty's own cwd so the spawned program + // launches in the directory the user started vibetty from. + let cwd = current_dir.or_else(|| std::env::current_dir().ok()); + if let Some(cwd) = cwd { + cmd.cwd(cwd); } - let child = cmd.spawn(pts)?; - log::debug!("Started terminal with PID {}", child.id().unwrap_or(0)); + let child = pair.slave.spawn_command(cmd)?; + log::debug!("Started terminal with PID {:?}", child.process_id()); + + // The parent no longer needs the slave handle; the child holds its own. + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader()?; + let (read_tx, read_rx) = mpsc::channel::>(128); + let (write_tx, mut write_rx) = mpsc::channel::(128); + // A second handle on the write channel so the reader thread can answer + // terminal queries (e.g. ConPTY's cursor-position report). + let query_tx = write_tx.clone(); + + // Reader thread: owns the blocking PTY reader, pushes output chunks onto a + // channel consumed by the async `read()` path. It also answers the + // cursor-position-report request (`ESC[6n`) that Windows ConPTY sends on + // startup — ConPTY stalls until the host replies, and Unix shells never + // send it, which is why this only manifested on Windows. + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + // ConPTY only sends ESC[6n once, at startup, so only scan the first + // read for it — never the steady-state output stream. + let mut first = true; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + let chunk = &buf[..n]; + if first { + if chunk.windows(4).any(|w| w == b"\x1b[6n") { + // Reply with cursor position row 1, col 1. + let (ack_tx, _ack_rx) = oneshot::channel(); + let _ = query_tx.blocking_send((b"\x1b[1;1R".to_vec(), ack_tx)); + } + first = false; + } + if read_tx.blocking_send(chunk.to_vec()).is_err() { + break; // receiver dropped -> stop pumping + } + } + Err(_) => break, + } + } + }); + + // Writer thread: drains input chunks from a channel and writes them to the + // PTY, acknowledging each request via a one-shot channel. + let mut writer = pair.master.take_writer()?; + tokio::task::spawn_blocking(move || { + while let Some((buf, ack)) = write_rx.blocking_recv() { + let res = writer.write_all(&buf).and_then(|_| writer.flush()); + let _ = ack.send(res); + } + }); Ok(EchokitChild { uuid: uuid::Uuid::new_v4(), - pty, - child, + master: pair.master, + child: Some(child), + read_rx, + write_tx, }) }