From f15e60be60037cfeb3c9d729ad912886e88aa893 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 5 Mar 2026 23:42:20 +0100 Subject: [PATCH] feat(v2): add PTY backend with portable-pty - Implement PtyManager: spawn, write, resize, kill operations - Wire Tauri commands: pty_spawn, pty_write, pty_resize, pty_kill - Reader thread streams PTY output via Tauri events (pty-data-{id}) - Process exit detection emits pty-exit-{id} event - Add portable-pty 0.8 and uuid 1.0 dependencies --- v2/src-tauri/Cargo.lock | 158 ++++++++++++++++++++++++++++++++++++++- v2/src-tauri/Cargo.toml | 2 + v2/src-tauri/src/lib.rs | 79 ++++++++++++++++---- v2/src-tauri/src/pty.rs | 162 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 384 insertions(+), 17 deletions(-) diff --git a/v2/src-tauri/Cargo.lock b/v2/src-tauri/Cargo.lock index 96cb8e3..2cd8736 100644 --- a/v2/src-tauri/Cargo.lock +++ b/v2/src-tauri/Cargo.lock @@ -222,11 +222,13 @@ name = "bterminal" version = "0.1.0" dependencies = [ "log", + "portable-pty", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-log", + "uuid", ] [[package]] @@ -699,6 +701,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -746,7 +754,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -806,10 +814,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1591,6 +1610,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1713,6 +1741,12 @@ dependencies = [ "selectors", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1835,6 +1869,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1928,6 +1971,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2347,6 +2404,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2996,6 +3074,48 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3039,6 +3159,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" @@ -3567,6 +3703,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4741,6 +4886,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/v2/src-tauri/Cargo.toml b/v2/src-tauri/Cargo.toml index 3a5208a..bf7ea50 100644 --- a/v2/src-tauri/Cargo.toml +++ b/v2/src-tauri/Cargo.toml @@ -22,3 +22,5 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-log = "2" +portable-pty = "0.8" +uuid = { version = "1", features = ["v4"] } diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 9c3118c..38b47e5 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -1,16 +1,69 @@ +mod pty; +mod sidecar; +mod watcher; +mod session; + +use pty::{PtyManager, PtyOptions}; +use std::sync::Arc; +use tauri::State; + +struct AppState { + pty_manager: Arc, +} + +#[tauri::command] +fn pty_spawn( + app: tauri::AppHandle, + state: State<'_, AppState>, + options: PtyOptions, +) -> Result { + state.pty_manager.spawn(&app, options) +} + +#[tauri::command] +fn pty_write(state: State<'_, AppState>, id: String, data: String) -> Result<(), String> { + state.pty_manager.write(&id, &data) +} + +#[tauri::command] +fn pty_resize( + state: State<'_, AppState>, + id: String, + cols: u16, + rows: u16, +) -> Result<(), String> { + state.pty_manager.resize(&id, cols, rows) +} + +#[tauri::command] +fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> { + state.pty_manager.kill(&id) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + let app_state = AppState { + pty_manager: Arc::new(PtyManager::new()), + }; + + tauri::Builder::default() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + pty_spawn, + pty_write, + pty_resize, + pty_kill, + ]) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/v2/src-tauri/src/pty.rs b/v2/src-tauri/src/pty.rs index 5188eda..744ebc4 100644 --- a/v2/src-tauri/src/pty.rs +++ b/v2/src-tauri/src/pty.rs @@ -1,2 +1,160 @@ -// PTY management via portable-pty -// Phase 2: spawn, resize, I/O streaming to frontend +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufReader, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use tauri::{AppHandle, Emitter}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PtyOptions { + pub shell: Option, + pub cwd: Option, + pub args: Option>, + pub cols: Option, + pub rows: Option, +} + +struct PtyInstance { + master: Box, + writer: Box, +} + +pub struct PtyManager { + instances: Arc>>, +} + +impl PtyManager { + pub fn new() -> Self { + Self { + instances: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn spawn( + &self, + app: &AppHandle, + options: PtyOptions, + ) -> Result { + let pty_system = native_pty_system(); + let cols = options.cols.unwrap_or(80); + let rows = options.rows.unwrap_or(24); + + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("Failed to open PTY: {e}"))?; + + let shell = options.shell.unwrap_or_else(|| { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) + }); + + let mut cmd = CommandBuilder::new(&shell); + if let Some(args) = &options.args { + for arg in args { + cmd.arg(arg); + } + } + if let Some(cwd) = &options.cwd { + cmd.cwd(cwd); + } + + let _child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn command: {e}"))?; + + // Drop the slave side — we only need the master + drop(pair.slave); + + let id = Uuid::new_v4().to_string(); + let reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; + let writer = pair + .master + .take_writer() + .map_err(|e| format!("Failed to take PTY writer: {e}"))?; + + // Spawn reader thread that emits Tauri events + let event_id = id.clone(); + let app_handle = app.clone(); + thread::spawn(move || { + let mut buf_reader = BufReader::with_capacity(4096, reader); + let mut buf = vec![0u8; 4096]; + loop { + match std::io::Read::read(&mut buf_reader, &mut buf) { + Ok(0) => { + // PTY closed + let _ = app_handle.emit(&format!("pty-exit-{event_id}"), ()); + break; + } + Ok(n) => { + let data = String::from_utf8_lossy(&buf[..n]).to_string(); + let _ = app_handle.emit(&format!("pty-data-{event_id}"), &data); + } + Err(e) => { + log::error!("PTY read error for {event_id}: {e}"); + let _ = app_handle.emit(&format!("pty-exit-{event_id}"), ()); + break; + } + } + } + }); + + let instance = PtyInstance { master: pair.master, writer }; + self.instances.lock().unwrap().insert(id.clone(), instance); + + log::info!("Spawned PTY {id} ({shell})"); + Ok(id) + } + + pub fn write(&self, id: &str, data: &str) -> Result<(), String> { + let mut instances = self.instances.lock().unwrap(); + let instance = instances + .get_mut(id) + .ok_or_else(|| format!("PTY {id} not found"))?; + instance + .writer + .write_all(data.as_bytes()) + .map_err(|e| format!("PTY write error: {e}"))?; + instance + .writer + .flush() + .map_err(|e| format!("PTY flush error: {e}"))?; + Ok(()) + } + + pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> { + let instances = self.instances.lock().unwrap(); + let instance = instances + .get(id) + .ok_or_else(|| format!("PTY {id} not found"))?; + instance + .master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("PTY resize error: {e}"))?; + Ok(()) + } + + pub fn kill(&self, id: &str) -> Result<(), String> { + let mut instances = self.instances.lock().unwrap(); + if instances.remove(id).is_some() { + log::info!("Killed PTY {id}"); + Ok(()) + } else { + Err(format!("PTY {id} not found")) + } + } +}