Download src-tauri/src/modules/shell/mod.rs from lekmikdok/termix: direct link, hf CLI and curl.
- Browser
- Download file 9.37 kB
-
https://huggingface.co/spaces/lekmikdok/termix/resolve/main/src-tauri/src/modules/shell/mod.rs
- Command line
-
hf download hf://spaces/lekmikdok/termix/src-tauri/src/modules/shell/mod.rs
-
curl -L -o mod.rs https://huggingface.co/spaces/lekmikdok/termix/resolve/main/src-tauri/src/modules/shell/mod.rs
9.37 kB
| pub mod background; | |
| pub mod ringbuffer; | |
| pub mod session; | |
| use std::collections::HashMap; | |
| use std::io::Read; | |
| use std::path::PathBuf; | |
| use std::process::{Command, Stdio}; | |
| use std::sync::atomic::{AtomicU32, Ordering}; | |
| use std::sync::{mpsc, Arc, RwLock}; | |
| use std::thread; | |
| use std::time::{Duration, Instant}; | |
| use serde::Serialize; | |
| use background::{BackgroundLogResponse, BackgroundProc, BackgroundProcInfo}; | |
| use session::{SessionRunOutput, ShellSession}; | |
| const DEFAULT_TIMEOUT_SECS: u64 = 30; | |
| const MAX_TIMEOUT_SECS: u64 = 300; | |
| const MAX_OUTPUT_BYTES: usize = 256 * 1024; | |
| const POLL_INTERVAL: Duration = Duration::from_millis(50); | |
| pub struct CommandOutput { | |
| pub stdout: String, | |
| pub stderr: String, | |
| pub exit_code: Option<i32>, | |
| pub timed_out: bool, | |
| pub truncated: bool, | |
| } | |
| /// Runs a one-shot command via the user's login shell. Output is capped and | |
| /// the process is force-killed on timeout. We deliberately do NOT pipe into | |
| /// the user's interactive PTY — that would fight their input. AI tool calls | |
| /// are presented in chat as their own structured result. | |
| pub async fn shell_run_command( | |
| command: String, | |
| cwd: Option<String>, | |
| timeout_secs: Option<u64>, | |
| ) -> Result<CommandOutput, String> { | |
| let trimmed = command.trim().to_string(); | |
| if trimmed.is_empty() { | |
| return Err("empty command".into()); | |
| } | |
| let cwd_path = if let Some(dir) = cwd.as_deref().filter(|s| !s.is_empty()) { | |
| let p = PathBuf::from(dir); | |
| if !p.is_dir() { | |
| return Err(format!("cwd is not a directory: {}", p.display())); | |
| } | |
| Some(p) | |
| } else { | |
| None | |
| }; | |
| let dur = Duration::from_secs( | |
| timeout_secs | |
| .unwrap_or(DEFAULT_TIMEOUT_SECS) | |
| .clamp(1, MAX_TIMEOUT_SECS), | |
| ); | |
| // The blocking spawn + wait runs on a worker thread so the Tauri async | |
| // runtime stays unblocked. | |
| let (tx, rx) = mpsc::channel::<Result<CommandOutput, String>>(); | |
| thread::spawn(move || { | |
| let _ = tx.send(run_blocking(trimmed, cwd_path, dur)); | |
| }); | |
| rx.recv().map_err(|e| e.to_string())? | |
| } | |
| pub(crate) fn run_blocking_inner( | |
| command: String, | |
| cwd: Option<PathBuf>, | |
| dur: Duration, | |
| ) -> Result<CommandOutput, String> { | |
| run_blocking(command, cwd, dur) | |
| } | |
| fn run_blocking( | |
| command: String, | |
| cwd: Option<PathBuf>, | |
| dur: Duration, | |
| ) -> Result<CommandOutput, String> { | |
| let mut cmd = build_oneshot_command(&command); | |
| if let Some(dir) = cwd { | |
| cmd.current_dir(dir); | |
| } | |
| cmd.stdin(Stdio::null()) | |
| .stdout(Stdio::piped()) | |
| .stderr(Stdio::piped()); | |
| let mut child = cmd.spawn().map_err(|e| { | |
| log::warn!("shell_run_command spawn failed: {e}"); | |
| e.to_string() | |
| })?; | |
| let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; | |
| let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; | |
| // Drain stdout/stderr on background threads so a full pipe buffer can't | |
| // deadlock the child. | |
| let stdout_handle = thread::spawn(move || drain(&mut stdout_pipe)); | |
| let stderr_handle = thread::spawn(move || drain(&mut stderr_pipe)); | |
| let started = Instant::now(); | |
| let mut timed_out = false; | |
| let exit_code: Option<i32> = loop { | |
| match child.try_wait() { | |
| Ok(Some(status)) => break status.code(), | |
| Ok(None) => {} | |
| Err(e) => return Err(e.to_string()), | |
| } | |
| if started.elapsed() >= dur { | |
| let _ = child.kill(); | |
| let _ = child.wait(); | |
| timed_out = true; | |
| break None; | |
| } | |
| thread::sleep(POLL_INTERVAL); | |
| }; | |
| let (stdout_bytes, stdout_truncated) = stdout_handle.join().unwrap_or((Vec::new(), false)); | |
| let (stderr_bytes, stderr_truncated) = stderr_handle.join().unwrap_or((Vec::new(), false)); | |
| Ok(CommandOutput { | |
| stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), | |
| stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), | |
| exit_code, | |
| timed_out, | |
| truncated: stdout_truncated || stderr_truncated, | |
| }) | |
| } | |
| // ────────────────────────────────────────────────────────────────────────── | |
| // Persistent agent shell state + background process state. | |
| // ────────────────────────────────────────────────────────────────────────── | |
| pub struct ShellState { | |
| sessions: RwLock<HashMap<u32, Arc<ShellSession>>>, | |
| bg: RwLock<HashMap<u32, Arc<BackgroundProc>>>, | |
| next_session_id: AtomicU32, | |
| next_bg_id: AtomicU32, | |
| } | |
| impl Default for ShellState { | |
| fn default() -> Self { | |
| Self { | |
| sessions: RwLock::new(HashMap::new()), | |
| bg: RwLock::new(HashMap::new()), | |
| next_session_id: AtomicU32::new(1), | |
| next_bg_id: AtomicU32::new(1), | |
| } | |
| } | |
| } | |
| pub fn shell_session_open( | |
| state: tauri::State<ShellState>, | |
| cwd: Option<String>, | |
| ) -> Result<u32, String> { | |
| let initial = match cwd.as_deref().filter(|s| !s.is_empty()) { | |
| Some(c) => { | |
| let p = PathBuf::from(c); | |
| if !p.is_dir() { | |
| return Err(format!("cwd is not a directory: {c}")); | |
| } | |
| p | |
| } | |
| None => dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")), | |
| }; | |
| let session = Arc::new(ShellSession::new(initial)); | |
| let id = state.next_session_id.fetch_add(1, Ordering::Relaxed); | |
| state.sessions.write().unwrap().insert(id, session); | |
| Ok(id) | |
| } | |
| pub async fn shell_session_run( | |
| state: tauri::State<'_, ShellState>, | |
| id: u32, | |
| command: String, | |
| cwd: Option<String>, | |
| timeout_secs: Option<u64>, | |
| ) -> Result<SessionRunOutput, String> { | |
| let session = state | |
| .sessions | |
| .read() | |
| .unwrap() | |
| .get(&id) | |
| .cloned() | |
| .ok_or_else(|| "no shell session".to_string())?; | |
| let dur = Duration::from_secs( | |
| timeout_secs | |
| .unwrap_or(DEFAULT_TIMEOUT_SECS) | |
| .clamp(1, MAX_TIMEOUT_SECS), | |
| ); | |
| let (tx, rx) = mpsc::channel(); | |
| thread::spawn(move || { | |
| let _ = tx.send(session.run(command, cwd, dur)); | |
| }); | |
| rx.recv().map_err(|e| e.to_string())? | |
| } | |
| pub fn shell_session_close(state: tauri::State<ShellState>, id: u32) -> Result<(), String> { | |
| state.sessions.write().unwrap().remove(&id); | |
| Ok(()) | |
| } | |
| pub fn shell_bg_spawn( | |
| state: tauri::State<ShellState>, | |
| command: String, | |
| cwd: Option<String>, | |
| ) -> Result<u32, String> { | |
| let proc = background::spawn(command, cwd)?; | |
| let id = state.next_bg_id.fetch_add(1, Ordering::Relaxed); | |
| state.bg.write().unwrap().insert(id, proc); | |
| Ok(id) | |
| } | |
| pub fn shell_bg_logs( | |
| state: tauri::State<ShellState>, | |
| handle: u32, | |
| since_offset: Option<u64>, | |
| ) -> Result<BackgroundLogResponse, String> { | |
| let proc = state | |
| .bg | |
| .read() | |
| .unwrap() | |
| .get(&handle) | |
| .cloned() | |
| .ok_or_else(|| "no background handle".to_string())?; | |
| Ok(proc.read_logs(since_offset.unwrap_or(0))) | |
| } | |
| pub fn shell_bg_kill(state: tauri::State<ShellState>, handle: u32) -> Result<(), String> { | |
| if let Some(proc) = state.bg.read().unwrap().get(&handle).cloned() { | |
| proc.kill(); | |
| } | |
| Ok(()) | |
| } | |
| pub fn shell_bg_list(state: tauri::State<ShellState>) -> Result<Vec<BackgroundProcInfo>, String> { | |
| let map = state.bg.read().unwrap(); | |
| let mut out = Vec::with_capacity(map.len()); | |
| for (id, p) in map.iter() { | |
| out.push(p.info(*id)); | |
| } | |
| out.sort_by_key(|i| i.handle); | |
| Ok(out) | |
| } | |
| pub(crate) fn build_oneshot_command(command: &str) -> Command { | |
| { | |
| let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); | |
| let mut cmd = Command::new(shell); | |
| cmd.arg("-lc").arg(command); | |
| cmd | |
| } | |
| { | |
| let shell = crate::modules::pty::shell_init::windows_shell_path(); | |
| let mut cmd = Command::new(&shell); | |
| let is_cmd = shell | |
| .file_name() | |
| .and_then(|s| s.to_str()) | |
| .map(|s| s.eq_ignore_ascii_case("cmd.exe")) | |
| .unwrap_or(false); | |
| if is_cmd { | |
| cmd.arg("/C").arg(command); | |
| } else { | |
| cmd.arg("-NoProfile").arg("-Command").arg(command); | |
| } | |
| cmd | |
| } | |
| } | |
| fn drain<R: Read>(reader: &mut R) -> (Vec<u8>, bool) { | |
| let mut out = Vec::new(); | |
| let mut buf = [0u8; 8192]; | |
| let mut truncated = false; | |
| loop { | |
| match reader.read(&mut buf) { | |
| Ok(0) => break, | |
| Ok(n) => { | |
| if out.len() >= MAX_OUTPUT_BYTES { | |
| truncated = true; | |
| continue; | |
| } | |
| let take = (MAX_OUTPUT_BYTES - out.len()).min(n); | |
| out.extend_from_slice(&buf[..take]); | |
| if take < n { | |
| truncated = true; | |
| } | |
| } | |
| Err(_) => break, | |
| } | |
| } | |
| (out, truncated) | |
| } | |