//! [`PortablePtyAdapter`] — local [`PtyPort`] implementation over the //! `portable-pty` crate (ARCHITECTURE §5, L3). //! //! # Design //! //! `portable-pty` is a blocking, thread-oriented API: a master PTY gives a //! `Box` reader and a `Box` writer, and the child process is //! waited on a thread. We bridge that to the domain [`PtyPort`] as follows: //! //! - [`PtyHandle`] only carries a [`SessionId`]; the *real* OS handles (master //! PTY, writer, child) live in this adapter's registry keyed by that id. The //! domain never sees an OS handle (ARCHITECTURE §4). //! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the //! slave, then start **one reader thread** that pumps bytes from the master //! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output) //! hands back the receiver wrapped as the domain's blocking [`OutputStream`] //! iterator; the presentation layer drains it on its own thread and forwards //! chunks to the per-session Tauri channel (the `PtyBridge`). //! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored //! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the //! reader thread, and returns the [`ExitStatus`]. //! //! # Cross-platform note (spike, ARCHITECTURE §13.1) //! //! `portable-pty` abstracts ConPTY on Windows, but exit-code/signal semantics //! differ: a Unix process killed by a signal reports `code: None` here, while //! ConPTY surfaces a numeric code. Resize uses the same `PtySize` everywhere. //! Points to validate on Windows: child `kill()` actually tears down ConPTY, and //! the reader thread observes EOF promptly on exit. The code below avoids any //! Unix-only assumption (no raw fds, no signals) so it should port as-is. use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::Mutex; use std::thread::JoinHandle; use async_trait::async_trait; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem}; use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec}; use domain::terminal::PtySize; use domain::SessionId; /// Size of each read buffer pumped from the master PTY. const READ_BUF: usize = 8 * 1024; /// A live PTY owned by the adapter. struct LivePty { /// Master side — used for resize. master: Box, /// Writer into the PTY (child stdin). writer: Box, /// The spawned child process. child: Box, /// Receiver end of the output channel; taken once by `subscribe_output`. output_rx: Option>>, /// Handle of the reader thread, joined on kill. reader: Option>, } /// Local PTY adapter backed by `portable-pty`'s native PTY system. /// /// Thread-safe: the registry of live PTYs is behind a [`Mutex`]; the adapter is /// cloneable-as-`Arc` and injected as `Arc` at the composition root. #[derive(Default)] pub struct PortablePtyAdapter { sessions: Mutex>, } impl PortablePtyAdapter { /// Creates an empty adapter. #[must_use] pub fn new() -> Self { Self { sessions: Mutex::new(HashMap::new()), } } } /// Maps the domain [`PtySize`] to the `portable-pty` one. fn to_pty_size(size: PtySize) -> portable_pty::PtySize { portable_pty::PtySize { rows: size.rows, cols: size.cols, pixel_width: 0, pixel_height: 0, } } /// Builds the `portable-pty` command from a domain [`SpawnSpec`]. fn to_command(spec: &SpawnSpec) -> CommandBuilder { let mut cmd = CommandBuilder::new(&spec.command); cmd.args(&spec.args); cmd.cwd(spec.cwd.as_str()); for (k, v) in &spec.env { cmd.env(k, v); } cmd } #[async_trait] impl PtyPort for PortablePtyAdapter { async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result { let pty_system = NativePtySystem::default(); let pair = pty_system .openpty(to_pty_size(size)) .map_err(|e| PtyError::Spawn(e.to_string()))?; let cmd = to_command(&spec); let child = pair .slave .spawn_command(cmd) .map_err(|e| PtyError::Spawn(e.to_string()))?; // The slave is held by the child; drop our copy so EOF propagates on exit. drop(pair.slave); let writer = pair .master .take_writer() .map_err(|e| PtyError::Io(e.to_string()))?; let mut reader = pair .master .try_clone_reader() .map_err(|e| PtyError::Io(e.to_string()))?; // One reader thread per PTY pumps bytes into the output channel until EOF. let (tx, rx): (Sender>, Receiver>) = mpsc::channel(); let reader_handle = std::thread::spawn(move || { let mut buf = [0u8; READ_BUF]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { // Receiver gone (session closed) → stop pumping. if tx.send(buf[..n].to_vec()).is_err() { break; } } Err(_) => break, } } }); // The PTY layer owns the handle identity: it mints a fresh session id and // the caller (the `OpenTerminal` use case) adopts it as the // `TerminalSession.id`. This keeps the OS handle out of the domain while // giving everyone a single, agreed-upon id (ARCHITECTURE §4). let handle = PtyHandle { session_id: SessionId::new_random(), }; let live = LivePty { master: pair.master, writer, child, output_rx: Some(rx), reader: Some(reader_handle), }; self.sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))? .insert(handle.session_id, live); Ok(handle) } fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { let mut map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; live.writer .write_all(data) .map_err(|e| PtyError::Io(e.to_string()))?; live.writer .flush() .map_err(|e| PtyError::Io(e.to_string())) } fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { let map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; live.master .resize(to_pty_size(size)) .map_err(|e| PtyError::Io(e.to_string())) } fn subscribe_output(&self, handle: &PtyHandle) -> Result { let mut map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; let rx = live .output_rx .take() .ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?; Ok(Box::new(rx.into_iter())) } async fn kill(&self, handle: &PtyHandle) -> Result { // Remove from the registry so the writer/master drop and the child is // fully owned here while we tear it down. let mut live = { let mut map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; map.remove(&handle.session_id).ok_or(PtyError::NotFound)? }; // Ask the child to terminate, then wait for its real status. let _ = live.child.kill(); let status = live .child .wait() .map_err(|e| PtyError::Io(e.to_string()))?; // Dropping master/writer closes the PTY; the reader thread then sees EOF. drop(live.output_rx.take()); if let Some(reader) = live.reader.take() { let _ = reader.join(); } Ok(ExitStatus { code: exit_code(&status), }) } } /// Extracts a portable exit code. `portable-pty`'s `ExitStatus` exposes /// `exit_code(): u32`; `0` is success. We surface it as `i32`. fn exit_code(status: &portable_pty::ExitStatus) -> Option { Some(status.exit_code() as i32) }