feat(runtime): add interactive local PTY sessions
test / workspace (push) Successful in 17m52s

This commit is contained in:
Tom You
2026-07-09 00:52:14 -05:00
parent ce370c740a
commit f37ac6cbf3
5 changed files with 516 additions and 3 deletions
Generated
+161 -2
View File
@@ -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"
+1
View File
@@ -16,3 +16,4 @@ base64 = "0.22"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
portable-pty = "0.8"
+1
View File
@@ -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;
+243
View File
@@ -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<TerminalSessionId, TerminalSession>,
}
struct TerminalSession {
master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
reader: Box<dyn Read + Send>,
writer: Box<dyn Write + Send>,
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<str>,
cols: u16,
rows: u16,
) -> Result<TerminalSessionId, TerminalSessionError> {
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<String, TerminalSessionError> {
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<TerminalSessionSize, TerminalSessionError> {
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<TerminalSessionSize, TerminalSessionError> {
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));
}
}
+110 -1
View File
@@ -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<TerminalSessionRegistry>);
#[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<TerminalState>,
shell: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
) -> Result<u64, String> {
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<TerminalState>,
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<TerminalState>,
id: u64,
needle: String,
timeout_ms: Option<u64>,
) -> Result<String, String> {
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<TerminalState>,
id: u64,
cols: u16,
rows: u16,
) -> Result<TerminalSessionSize, String> {
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<TerminalState>, 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<String>) -> 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<String>) -> 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()