//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty, //! reads its output on a background thread, and forwards chunks to the //! frontend as `pane://{id}/data` events. use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use parking_lot::Mutex; use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; use serde::Serialize; use tauri::{AppHandle, Emitter}; /// Sentinel "distro" name used to spawn Windows PowerShell instead of WSL. /// Frontend appends this to the distro list it shows in the dropdown. pub const POWERSHELL_DISTRO: &str = "PowerShell"; pub type PaneId = u64; /// What we keep alive for each spawned PTY. /// /// `master` stays in scope to keep the PTY alive; we never write through it /// directly (we use `writer` instead) and we never read through it directly /// (the reader thread holds its own clone via `try_clone_reader`). struct PaneHandle { #[allow(dead_code)] master: Box, writer: Box, #[allow(dead_code)] child: Box, } pub struct PtyManager { panes: Mutex>, next_id: AtomicU64, } impl PtyManager { pub fn new() -> Self { Self { panes: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), } } /// Spawn `wsl.exe` (optionally `-d `, optionally `--cd `). /// Returns the new pane id. A background thread starts reading the PTY /// immediately and emits `pane://{id}/data` events. pub fn spawn_wsl( &self, app: AppHandle, distro: Option, cwd: Option, cols: u16, rows: u16, ) -> Result { let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .context("openpty failed")?; let is_powershell = distro.as_deref() == Some(POWERSHELL_DISTRO); let cmd = if is_powershell { // cwd from the leaf is ignored — leaves may carry Linux-style // paths (e.g. `~`, `/mnt/d/...`) from a previously-assigned WSL // distro that PowerShell wouldn't understand. PowerShell starts // in its own default cwd; user can `cd` if they want. let mut c = CommandBuilder::new("powershell.exe"); c.arg("-NoLogo"); c } else { let mut c = CommandBuilder::new("wsl.exe"); if let Some(d) = distro.as_deref() { c.arg("-d"); c.arg(d); } // Default new panes to the WSL user's home (~) rather than the // Windows-side cwd we inherit from the launcher (typically // C:\Users\, which shows up as /mnt/c/Users/ inside WSL). // wsl.exe resolves `~` against the distro's default shell. let resolved_cwd = cwd.as_deref().unwrap_or("~"); c.arg("--cd"); c.arg(resolved_cwd); // wsl.exe without an explicit command launches the default shell // interactively, which is exactly what we want. c }; let spawn_err = if is_powershell { "failed to spawn powershell.exe" } else { "failed to spawn wsl.exe; is WSL installed?" }; let child = pair.slave.spawn_command(cmd).context(spawn_err)?; // We need to keep the master alive (drop = close the PTY), but we // also need the reader and writer split from it. let mut reader = pair .master .try_clone_reader() .context("try_clone_reader failed")?; let writer = pair .master .take_writer() .context("take_writer failed")?; let id = self.next_id.fetch_add(1, Ordering::Relaxed); self.panes.lock().insert( id, PaneHandle { master: pair.master, writer, child, }, ); // Reader thread: pump bytes -> base64 -> emit. let app_for_reader = app.clone(); let event_name = format!("pane://{id}/data"); std::thread::spawn(move || { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) => { tracing::info!("pane {id}: EOF"); let _ = app_for_reader.emit(&format!("pane://{id}/exit"), ()); break; } Ok(n) => { let chunk_b64 = B64.encode(&buf[..n]); if let Err(e) = app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 }) { tracing::warn!("emit failed for pane {id}: {e}"); } } Err(e) => { tracing::warn!("pane {id} read error: {e}"); let _ = app_for_reader.emit(&format!("pane://{id}/exit"), ()); break; } } } }); Ok(id) } pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> { let mut panes = self.panes.lock(); let pane = panes .get_mut(&id) .ok_or_else(|| anyhow!("no pane with id {id}"))?; pane.writer.write_all(bytes).context("pty write failed")?; pane.writer.flush().ok(); Ok(()) } pub fn resize(&self, id: PaneId, cols: u16, rows: u16) -> Result<()> { let panes = self.panes.lock(); let pane = panes .get(&id) .ok_or_else(|| anyhow!("no pane with id {id}"))?; pane.master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .context("pty resize failed")?; Ok(()) } pub fn kill(&self, id: PaneId) -> Result<()> { let mut panes = self.panes.lock(); if let Some(mut pane) = panes.remove(&id) { // Best-effort: ask the child to die. Dropping `master` after this // closes the PTY which will unblock the reader thread. let _ = pane.child.kill(); } Ok(()) } } #[derive(Serialize, Clone)] struct DataChunk { b64: String, } // ---- distro enumeration ----------------------------------------------------- /// Run a process without flashing a console window on Windows. fn quiet_command(program: &str) -> std::process::Command { let mut c = std::process::Command::new(program); #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; c.creation_flags(CREATE_NO_WINDOW); } c } /// `wsl.exe -l -q` lists installed distros (one per line, UTF-16LE). /// Returns Ok(empty) on non-Windows or when wsl.exe isn't on PATH. pub fn list_wsl_distros() -> Result> { if !cfg!(windows) { return Ok(Vec::new()); } let out = match quiet_command("wsl.exe").args(["-l", "-q"]).output() { Ok(o) => o, Err(e) => { tracing::debug!("wsl.exe not available: {e}"); return Ok(Vec::new()); } }; if !out.status.success() { return Ok(Vec::new()); } let raw_u16: Vec = out .stdout .chunks_exact(2) .map(|b| u16::from_le_bytes([b[0], b[1]])) .collect(); let decoded = String::from_utf16_lossy(&raw_u16); let distros: Vec = decoded .lines() .map(|l| { l.trim_matches(|c: char| c == '\u{FEFF}' || c.is_whitespace()) .to_string() }) .filter(|l| !l.is_empty()) .collect(); Ok(distros) }