| 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); |
|
|
| #[derive(Serialize)] |
| pub struct CommandOutput { |
| pub stdout: String, |
| pub stderr: String, |
| pub exit_code: Option<i32>, |
| pub timed_out: bool, |
| pub truncated: bool, |
| } |
|
|
| |
| |
| |
| |
| #[tauri::command] |
| 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), |
| ); |
|
|
| |
| |
| 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")?; |
|
|
| |
| |
| 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, |
| }) |
| } |
|
|
| |
| |
| |
|
|
| 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), |
| } |
| } |
| } |
|
|
| #[tauri::command] |
| 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) |
| } |
|
|
| #[tauri::command] |
| 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())? |
| } |
|
|
| #[tauri::command] |
| pub fn shell_session_close(state: tauri::State<ShellState>, id: u32) -> Result<(), String> { |
| state.sessions.write().unwrap().remove(&id); |
| Ok(()) |
| } |
|
|
| #[tauri::command] |
| 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) |
| } |
|
|
| #[tauri::command] |
| 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))) |
| } |
|
|
| #[tauri::command] |
| 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(()) |
| } |
|
|
| #[tauri::command] |
| 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 { |
| #[cfg(unix)] |
| { |
| 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 |
| } |
| #[cfg(windows)] |
| { |
| 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) |
| } |
|
|