diff --git a/Cargo.lock b/Cargo.lock index 12309ae..431260c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -713,6 +713,12 @@ dependencies = [ "tendril", ] +[[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" @@ -775,7 +781,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -822,10 +828,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" @@ -1568,6 +1585,15 @@ dependencies = [ "generic-array", ] +[[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" @@ -1691,6 +1717,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -1787,6 +1819,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[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" @@ -1874,6 +1915,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 = "num-conv" version = "0.2.2" @@ -2248,6 +2303,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -2305,6 +2366,27 @@ dependencies = [ "universal-hash", ] +[[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.5" @@ -2453,6 +2535,7 @@ dependencies = [ "aes-gcm", "argon2", "base64 0.22.1", + "portable-pty", "rabby-core", "rand", "serde", @@ -2841,6 +2924,48 @@ dependencies = [ "syn 2.0.118", ] +[[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" @@ -2883,6 +3008,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 = "2.0.1" @@ -3358,6 +3499,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" @@ -4427,6 +4577,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/rabby-runtime/Cargo.toml b/rabby-runtime/Cargo.toml index 65dea3e..9cb57f0 100644 --- a/rabby-runtime/Cargo.toml +++ b/rabby-runtime/Cargo.toml @@ -16,3 +16,4 @@ base64 = "0.22" rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" +portable-pty = "0.8" diff --git a/rabby-runtime/src/lib.rs b/rabby-runtime/src/lib.rs index 43ec6d2..1220210 100644 --- a/rabby-runtime/src/lib.rs +++ b/rabby-runtime/src/lib.rs @@ -2,4 +2,5 @@ pub mod config_store; pub mod local_terminal; pub mod pty; pub mod ssh_runtime; +pub mod terminal_session; pub mod wallet_vault; diff --git a/rabby-runtime/src/terminal_session/mod.rs b/rabby-runtime/src/terminal_session/mod.rs new file mode 100644 index 0000000..f44a326 --- /dev/null +++ b/rabby-runtime/src/terminal_session/mod.rs @@ -0,0 +1,243 @@ +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt; +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TerminalSessionId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct TerminalSessionSize { + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum TerminalSessionError { + InvalidSize, + MissingSession(u64), + Io(String), + Timeout(String), +} + +impl fmt::Display for TerminalSessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSize => write!(f, "invalid terminal session size"), + Self::MissingSession(id) => write!(f, "terminal session {id} does not exist"), + Self::Io(message) => write!(f, "terminal session I/O error: {message}"), + Self::Timeout(needle) => write!(f, "timed out waiting for terminal output: {needle}"), + } + } +} + +impl std::error::Error for TerminalSessionError {} + +pub struct TerminalSessionRegistry { + next_id: u64, + sessions: HashMap, +} + +struct TerminalSession { + master: Box, + child: Box, + reader: Box, + writer: Box, + size: TerminalSessionSize, +} + +impl Default for TerminalSessionRegistry { + fn default() -> Self { + Self { + next_id: 1, + sessions: HashMap::new(), + } + } +} + +impl TerminalSessionRegistry { + pub fn spawn_local( + &mut self, + shell: impl AsRef, + cols: u16, + rows: u16, + ) -> Result { + let size = session_size(cols, rows)?; + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| TerminalSessionError::Io(err.to_string()))?; + let command = CommandBuilder::new(shell.as_ref()); + let child = pair + .slave + .spawn_command(command) + .map_err(|err| TerminalSessionError::Io(err.to_string()))?; + let reader = pair + .master + .try_clone_reader() + .map_err(|err| TerminalSessionError::Io(err.to_string()))?; + let writer = pair + .master + .take_writer() + .map_err(|err| TerminalSessionError::Io(err.to_string()))?; + let id = TerminalSessionId(self.next_id); + self.next_id += 1; + self.sessions.insert( + id, + TerminalSession { + master: pair.master, + child, + reader, + writer, + size, + }, + ); + Ok(id) + } + + pub fn write( + &mut self, + id: TerminalSessionId, + bytes: &[u8], + ) -> Result<(), TerminalSessionError> { + let session = self.session_mut(id)?; + session + .writer + .write_all(bytes) + .map_err(|err| TerminalSessionError::Io(err.to_string()))?; + session + .writer + .flush() + .map_err(|err| TerminalSessionError::Io(err.to_string())) + } + + pub fn read_until( + &mut self, + id: TerminalSessionId, + needle: &str, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + let mut output = String::new(); + let mut buf = [0u8; 4096]; + while Instant::now() < deadline { + let session = self.session_mut(id)?; + match session.reader.read(&mut buf) { + Ok(0) => std::thread::sleep(Duration::from_millis(10)), + Ok(n) => { + output.push_str(&String::from_utf8_lossy(&buf[..n])); + if output.contains(needle) { + return Ok(output); + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(err) => return Err(TerminalSessionError::Io(err.to_string())), + } + } + Err(TerminalSessionError::Timeout(needle.to_string())) + } + + pub fn resize( + &mut self, + id: TerminalSessionId, + cols: u16, + rows: u16, + ) -> Result<(), TerminalSessionError> { + let size = session_size(cols, rows)?; + let session = self.session_mut(id)?; + session + .master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| TerminalSessionError::Io(err.to_string()))?; + session.size = size; + Ok(()) + } + + pub fn size(&self, id: TerminalSessionId) -> Result { + self.sessions + .get(&id) + .map(|session| session.size) + .ok_or(TerminalSessionError::MissingSession(id.0)) + } + + pub fn kill(&mut self, id: TerminalSessionId) -> Result<(), TerminalSessionError> { + let mut session = self + .sessions + .remove(&id) + .ok_or(TerminalSessionError::MissingSession(id.0))?; + session + .child + .kill() + .map_err(|err| TerminalSessionError::Io(err.to_string())) + } + + fn session_mut( + &mut self, + id: TerminalSessionId, + ) -> Result<&mut TerminalSession, TerminalSessionError> { + self.sessions + .get_mut(&id) + .ok_or(TerminalSessionError::MissingSession(id.0)) + } +} + +fn session_size(cols: u16, rows: u16) -> Result { + if cols == 0 || rows == 0 { + return Err(TerminalSessionError::InvalidSize); + } + Ok(TerminalSessionSize { cols, rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn session_registry_spawns_shell_and_reads_output() { + let mut registry = TerminalSessionRegistry::default(); + let id = registry.spawn_local("/bin/sh", 80, 24).unwrap(); + + registry + .write(id, b"printf rabby-pty-ready\nexit\n") + .unwrap(); + let output = registry + .read_until(id, "rabby-pty-ready", Duration::from_secs(3)) + .unwrap(); + + assert!(output.contains("rabby-pty-ready")); + } + + #[test] + fn session_registry_resizes_running_session() { + let mut registry = TerminalSessionRegistry::default(); + let id = registry.spawn_local("/bin/sh", 80, 24).unwrap(); + + registry.resize(id, 120, 40).unwrap(); + let size = registry.size(id).unwrap(); + + assert_eq!(size.cols, 120); + assert_eq!(size.rows, 40); + registry.kill(id).unwrap(); + } + + #[test] + fn session_registry_rejects_missing_session() { + let mut registry = TerminalSessionRegistry::default(); + let err = registry.write(TerminalSessionId(999), b"x").unwrap_err(); + + assert_eq!(err, TerminalSessionError::MissingSession(999)); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ca624f8..e52612e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,9 +5,16 @@ use rabby_core::{ use rabby_runtime::config_store::{ConfigStore, JsonFileConfigStore}; use rabby_runtime::local_terminal::{LocalCommand, LocalCommandOutput, LocalTerminal}; use rabby_runtime::ssh_runtime::{SshClient, SshCommand, SshCommandOutput}; +use rabby_runtime::terminal_session::{ + TerminalSessionId, TerminalSessionRegistry, TerminalSessionSize, +}; use rabby_runtime::wallet_vault::{EncryptedWalletVault, WalletVaultStatus}; use serde::Serialize; use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Duration; + +struct TerminalState(Mutex); #[derive(Serialize)] struct FeatureRow { @@ -92,6 +99,90 @@ fn run_ssh_command( .map_err(|err| format!("{err:?}")) } +#[tauri::command] +fn terminal_session_start( + state: tauri::State, + shell: Option, + cols: Option, + rows: Option, +) -> Result { + let shell = + shell.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())); + let mut registry = state + .0 + .lock() + .map_err(|_| "terminal registry lock poisoned".to_string())?; + registry + .spawn_local(shell, cols.unwrap_or(80), rows.unwrap_or(24)) + .map(|id| id.0) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn terminal_session_write( + state: tauri::State, + id: u64, + input: String, +) -> Result<(), String> { + let mut registry = state + .0 + .lock() + .map_err(|_| "terminal registry lock poisoned".to_string())?; + registry + .write(TerminalSessionId(id), input.as_bytes()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn terminal_session_read_until( + state: tauri::State, + id: u64, + needle: String, + timeout_ms: Option, +) -> Result { + let mut registry = state + .0 + .lock() + .map_err(|_| "terminal registry lock poisoned".to_string())?; + registry + .read_until( + TerminalSessionId(id), + &needle, + Duration::from_millis(timeout_ms.unwrap_or(1500)), + ) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn terminal_session_resize( + state: tauri::State, + id: u64, + cols: u16, + rows: u16, +) -> Result { + let mut registry = state + .0 + .lock() + .map_err(|_| "terminal registry lock poisoned".to_string())?; + registry + .resize(TerminalSessionId(id), cols, rows) + .map_err(|err| err.to_string())?; + registry + .size(TerminalSessionId(id)) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn terminal_session_kill(state: tauri::State, id: u64) -> Result<(), String> { + let mut registry = state + .0 + .lock() + .map_err(|_| "terminal registry lock poisoned".to_string())?; + registry + .kill(TerminalSessionId(id)) + .map_err(|err| err.to_string()) +} + fn resolve_vault_path(path: Option) -> PathBuf { path.map(PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("rabby-demo-wallet-vault.json")) @@ -124,6 +215,9 @@ fn resolve_config_path(path: Option) -> PathBuf { fn main() { tauri::Builder::default() + .manage(TerminalState( + Mutex::new(TerminalSessionRegistry::default()), + )) .invoke_handler(tauri::generate_handler![ workspace_summary, wallet_mvp_summary, @@ -133,7 +227,12 @@ fn main() { run_local_command, run_ssh_command, load_app_config, - save_app_config + save_app_config, + terminal_session_start, + terminal_session_write, + terminal_session_read_until, + terminal_session_resize, + terminal_session_kill ]) .run(tauri::generate_context!()) .expect("failed to run Rabby Tauri application"); @@ -181,6 +280,16 @@ mod tests { ); } + #[test] + fn terminal_session_size_is_returnable_from_resize_command() { + let size = TerminalSessionSize { + cols: 100, + rows: 30, + }; + assert_eq!(size.cols, 100); + assert_eq!(size.rows, 30); + } + #[test] fn save_and_load_app_config_round_trips_custom_theme() { let unique = std::time::SystemTime::now()