//! 进程探测与流式执行辅助(内部) //! //! 所有命令经 `agentdock-exec::validate_argv` 校验后执行,禁止 shell 拼接。 //! 探测类命令(`--version`)隐藏控制台窗口(Windows CREATE_NO_WINDOW)。 //! 流式执行(`run_streaming` / `run_streaming_cancellable`)用两条读取线程 //! **并发**消费 stdout 与 stderr,再经 mpsc 通道按到达顺序回调——保证实时滚动, //! 避免「先读 stdout 再读 stderr」造成的 stderr 延迟到进程结束时才出现的旧行为。 use std::io::BufRead; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; /// 按平台分隔符拆分 PATH。 fn path_entries() -> Vec { std::env::var("PATH") .unwrap_or_default() .split(if cfg!(windows) { ';' } else { ':' }) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect() } /// 在 PATH 中查找可执行文件(返回全部命中,用于版本冲突检测)。 /// Windows 优先 .exe/.cmd/.bat,最后回退无扩展名。 pub fn which_all(program: &str) -> Vec { let direct = Path::new(program); if direct.is_absolute() && direct.is_file() { return vec![direct.to_path_buf()]; } let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] }; let mut found = Vec::new(); for dir in path_entries() { for ext in exts { let cand = Path::new(&dir).join(format!("{program}{ext}")); if cand.is_file() && !found.contains(&cand) { found.push(cand); } } } found } /// 在 PATH 中解析可执行文件为可运行路径(找不到时回退原 prog,让 spawn 报错)。 pub fn resolve_exe(program: &str) -> String { which_all(program) .first() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| program.to_string()) } /// 运行只读探测命令并返回 (exit_ok, stdout+stderr 合并文本)。 pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String)> { let mut cmd = Command::new(exe); cmd.args(args); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } let out = cmd.output()?; let mut text = String::from_utf8_lossy(&out.stdout).to_string(); if text.trim().is_empty() { text = String::from_utf8_lossy(&out.stderr).to_string(); } Ok((out.status.success(), text)) } /// 运行命令并把一段文本写入其 stdin(API Key 经 stdin 注入官方登录命令用), /// 返回 (exit_ok, stdout+stderr 合并文本)。输入内容绝不明文落日志。 pub fn run_with_stdin(exe: &Path, args: &[String], stdin_text: &str) -> std::io::Result<(bool, String)> { let mut cmd = Command::new(exe); cmd.args(args); cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } let mut child = cmd.spawn()?; if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(stdin_text.as_bytes()); // 关闭 stdin,通知子进程输入结束 } let out = child.wait_with_output()?; let mut text = String::from_utf8_lossy(&out.stdout).to_string(); if text.trim().is_empty() { text = String::from_utf8_lossy(&out.stderr).to_string(); } Ok((out.status.success(), text)) } /// 可取消的进程句柄:`cancel` 置位后流式读取循环会终止子进程; /// 授权等交互式流程需要取消时调用 `request_cancel`。 #[derive(Default)] pub struct RunningProcess { cancel: Arc, child: Arc>>, } impl RunningProcess { pub fn new() -> Self { RunningProcess::default() } /// 请求取消(安全幂等)。 pub fn request_cancel(&self) { self.cancel.store(true, Ordering::SeqCst); } /// 取取消标志的共享句柄(供其它线程定时/条件取消)。 pub fn cancel_flag(&self) -> Arc { self.cancel.clone() } pub fn is_cancelled(&self) -> bool { self.cancel.load(Ordering::SeqCst) } fn kill(&self) { if let Some(mut child) = self.child.lock().unwrap().take() { kill_child_tree(&mut child); } } } /// 流式执行命令,逐行回调(stdout 与 stderr 并发、按到达顺序实时输出)。 /// 返回是否成功。 pub fn run_streaming(exe: &str, args: &[String], on_line: F) -> std::io::Result where F: FnMut(bool, &str), { let rp = RunningProcess::new(); run_streaming_cancellable(&rp, exe, args, on_line) } /// 可取消的流式执行:stdout/stderr 并发读取,主线程按行回调; /// 取消置位时终止子进程并结束。 pub fn run_streaming_cancellable( rp: &RunningProcess, exe: &str, args: &[String], mut on_line: F, ) -> std::io::Result where F: FnMut(bool, &str), { let mut cmd = Command::new(exe); cmd.args(args); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); } let mut child = cmd.spawn()?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); *rp.child.lock().unwrap() = Some(child); let (tx, rx) = mpsc::channel::<(bool, String)>(); // stdout 读取线程 if let Some(out) = stdout { let tx = tx.clone(); std::thread::spawn(move || { let reader = std::io::BufReader::new(out); for line in reader.lines().map_while(Result::ok) { if tx.send((false, line)).is_err() { break; } } }); } // stderr 读取线程 if let Some(err) = stderr { let tx = tx.clone(); std::thread::spawn(move || { let reader = std::io::BufReader::new(err); for line in reader.lines().map_while(Result::ok) { if tx.send((true, line)).is_err() { break; } } }); } drop(tx); // 主线程持有的发送端关闭,两线程结束后通道自然关闭 // 主线程按到达顺序实时回调(recv_timeout 兜底:静默期也能响应取消) loop { match rx.recv_timeout(std::time::Duration::from_millis(200)) { Ok((is_err, line)) => { on_line(is_err, &line); if rp.is_cancelled() { rp.kill(); break; } } Err(mpsc::RecvTimeoutError::Timeout) => { if rp.is_cancelled() { rp.kill(); break; } } Err(mpsc::RecvTimeoutError::Disconnected) => break, } } // 等待子进程退出并取状态 let status = rp .child .lock() .unwrap() .take() .and_then(|mut c| c.wait().ok()) .map(|s| s.success()) .unwrap_or(false); Ok(status) } /// 终止子进程(Windows 用 taskkill /T 杀掉整棵进程树,避免 .cmd 包装的 node 等子进程变孤儿)。 fn kill_child_tree(child: &mut Child) { #[cfg(windows)] { use std::os::windows::process::CommandExt; let pid = child.id(); let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .creation_flags(0x0800_0000) .status(); let _ = child.kill(); let _ = child.wait(); } #[cfg(not(windows))] { let _ = child.kill(); let _ = child.wait(); } } /// 在**独立控制台窗口**中运行命令(一次性本机终端,PRD FR-06 设计), /// 结束后窗口自动关闭。仅回传退出成功与否,不回传输出。 /// 用于必须在终端里交互的登录流程(local_tui 等)。 #[cfg(windows)] pub fn run_terminal(exe: &str, args: &[String]) -> std::io::Result { use std::os::windows::process::CommandExt; let mut cmd = Command::new(exe); cmd.args(args); cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE let status = cmd.status()?; Ok(status.success()) } /// 非 Windows 平台:终端窗口回退为普通前台执行。 #[cfg(not(windows))] pub fn run_terminal(exe: &str, args: &[String]) -> std::io::Result { let status = Command::new(exe).args(args).status()?; Ok(status.success()) } /// 打开本机默认程序/浏览器(文件用默认处理器打开、URL 用默认浏览器打开)。 /// 仅接受已通过来源白名单校验的目标,不做 shell 拼接。 #[cfg(windows)] pub fn open_with_shell(target: &str) -> std::io::Result<()> { // explorer.exe 同时能打开文件(默认处理器)与 URL(默认浏览器) let status = Command::new("explorer.exe").arg(target).status()?; if status.success() { Ok(()) } else { Err(std::io::Error::new( std::io::ErrorKind::Other, "explorer 打开目标失败", )) } } #[cfg(not(windows))] pub fn open_with_shell(target: &str) -> std::io::Result<()> { let status = Command::new("xdg-open").arg(target).status()?; if status.success() { Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::Other, "xdg-open 打开目标失败")) } } #[cfg(test)] mod tests { use super::*; #[test] fn run_streaming_reports_stdout_in_order() { // 用 echo 输出多行,验证 stdout 被逐行实时捕获 #[cfg(windows)] let (exe, args) = ( "cmd.exe".to_string(), vec!["/C".to_string(), "echo line1 & echo line2 & echo line3".to_string()], ); #[cfg(not(windows))] let (exe, args) = ("sh".to_string(), vec!["-c".to_string(), "echo line1; echo line2; echo line3".to_string()]); let mut lines = Vec::new(); let ok = run_streaming(&exe, &args, |is_err, l| { if !is_err { lines.push(l.to_string()); } }) .unwrap(); assert!(ok); assert!(lines.iter().any(|l| l.contains("line1"))); assert!(lines.iter().any(|l| l.contains("line3"))); } #[test] fn running_process_cancel_is_idempotent() { let rp = RunningProcess::new(); assert!(!rp.is_cancelled()); rp.request_cancel(); assert!(rp.is_cancelled()); rp.request_cancel(); assert!(rp.is_cancelled()); } }