tiletopia/src-tauri/src/pty.rs

250 lines
8.1 KiB
Rust

//! 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<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
#[allow(dead_code)]
child: Box<dyn portable_pty::Child + Send + Sync>,
}
pub struct PtyManager {
panes: Mutex<HashMap<PaneId, PaneHandle>>,
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 <distro>`, optionally `--cd <cwd>`).
/// 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<String>,
cwd: Option<String>,
cols: u16,
rows: u16,
) -> Result<PaneId> {
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\<you>, which shows up as /mnt/c/Users/<you> 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<Vec<String>> {
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<u16> = 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<String> = 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)
}