Tauri 2 + Svelte 5 + xterm.js + portable-pty. Single full-window WSL terminal pane with clickable distro picker. M1 verified manually on Windows: window opens, xterm.js renders, claude TUI works, resize reflows cleanly. Graduated from ~/claude/ideas/wsl-mux/ per the approved plan at ~/.claude/plans/imperative-coalescing-feigenbaum.md. See memory.md for decisions, open TODOs, and the M2-M5 roadmap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
7 KiB
Rust
229 lines
7 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};
|
|
|
|
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 mut cmd = CommandBuilder::new("wsl.exe");
|
|
if let Some(d) = distro.as_deref() {
|
|
cmd.arg("-d");
|
|
cmd.arg(d);
|
|
}
|
|
if let Some(c) = cwd.as_deref() {
|
|
cmd.arg("--cd");
|
|
cmd.arg(c);
|
|
}
|
|
// Force a login shell so .bashrc etc. run and PATH is populated.
|
|
// wsl.exe without an explicit command launches the default shell
|
|
// interactively, which is exactly what we want.
|
|
|
|
let child = pair
|
|
.slave
|
|
.spawn_command(cmd)
|
|
.context("failed to spawn wsl.exe; is WSL installed?")?;
|
|
|
|
// 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)
|
|
}
|