iris_hub/services/
process_manager.rs1use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::process::{Child, Command};
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19#[cfg(windows)]
20use std::os::windows::process::CommandExt;
21
22use crate::core::{AppConfig, RunningProcess};
23
24#[cfg(windows)]
26const CREATE_NO_WINDOW: u32 = 0x08000000;
27
28pub struct ProcessManager {
43 running_apps: Arc<Mutex<HashMap<String, RunningProcess>>>,
45
46 loading_apps: Arc<Mutex<HashSet<String>>>,
48}
49
50impl ProcessManager {
51 pub fn new() -> Self {
53 Self {
54 running_apps: Arc::new(Mutex::new(HashMap::new())),
55 loading_apps: Arc::new(Mutex::new(HashSet::new())),
56 }
57 }
58
59 pub fn running_apps(&self) -> Arc<Mutex<HashMap<String, RunningProcess>>> {
61 Arc::clone(&self.running_apps)
62 }
63
64 pub fn loading_apps(&self) -> Arc<Mutex<HashSet<String>>> {
66 Arc::clone(&self.loading_apps)
67 }
68
69 pub fn launch_app(&self, app: &AppConfig) {
81 if app.commands.is_empty() {
82 return;
83 }
84
85 self.stop_app(&app.id, Some(&app.name), Some(&app.commands));
87
88 {
90 let mut loading = self.loading_apps.lock().unwrap();
91 loading.insert(app.id.clone());
92 }
93
94 let app_clone = app.clone();
96 let running_apps = Arc::clone(&self.running_apps);
97 let loading_apps = Arc::clone(&self.loading_apps);
98
99 std::thread::spawn(move || {
101 Self::launch_in_thread(app_clone, running_apps, loading_apps);
102 });
103 }
104
105 fn launch_in_thread(
107 app: AppConfig,
108 running_apps: Arc<Mutex<HashMap<String, RunningProcess>>>,
109 loading_apps: Arc<Mutex<HashSet<String>>>,
110 ) {
111 let temp_dir = std::env::temp_dir();
113 let batch_file = temp_dir.join(format!("iris_{}.bat", app.id));
114
115 let batch_content = Self::build_batch_content(&app);
117
118 if fs::write(&batch_file, &batch_content).is_err() {
119 let mut loading = loading_apps.lock().unwrap();
120 loading.remove(&app.id);
121 return;
122 }
123
124 let child = Command::new("cmd")
126 .args(["/C", "start", "", &batch_file.to_string_lossy()])
127 .spawn();
128
129 std::thread::sleep(Duration::from_millis(800));
131
132 if let Ok(child) = child {
133 let console_pid = Self::find_console_pid(&app.name);
135
136 let mut running = running_apps.lock().unwrap();
137 running.insert(app.id.clone(), RunningProcess::new(child, console_pid));
138 }
139
140 let mut loading = loading_apps.lock().unwrap();
142 loading.remove(&app.id);
143 }
144
145 fn build_batch_content(app: &AppConfig) -> String {
150 let mut batch_content = String::new();
151 batch_content.push_str("@echo off\n");
152 batch_content.push_str(&format!("title [IRIS] {}\n", app.name));
153
154 if !app.working_dir.is_empty() {
155 batch_content.push_str(&format!("cd /d \"{}\"\n", app.working_dir));
156 }
157
158 let commands = &app.commands;
159 let mut i = 0;
160 while i < commands.len() {
161 let cmd = &commands[i];
162 let cmd_lower = cmd.to_lowercase();
163
164 let next_is_input = if i + 1 < commands.len() {
166 let next = &commands[i + 1];
167 next.chars().all(|c| c.is_numeric() || c == '.')
168 || next.eq_ignore_ascii_case("s")
169 || next.eq_ignore_ascii_case("n")
170 || next.eq_ignore_ascii_case("y")
171 } else {
172 false
173 };
174
175 if next_is_input && (cmd_lower.ends_with(".bat") || cmd_lower.ends_with(".cmd")) {
177 let input = &commands[i + 1];
178 let input_file = format!("iris_input_{}.txt", app.id);
179 batch_content.push_str(&format!("echo {}> %TEMP%\\{}\n", input, input_file));
180 batch_content.push_str(&format!("call {} < %TEMP%\\{}\n", cmd, input_file));
181 batch_content.push_str(&format!("del %TEMP%\\{} 2>nul\n", input_file));
182 i += 2;
183 batch_content.push_str(&format!("title [IRIS] {}\n", app.name));
184 continue;
185 }
186
187 let needs_call = cmd_lower.starts_with("npm ")
189 || cmd_lower.starts_with("yarn ")
190 || cmd_lower.starts_with("pnpm ")
191 || cmd_lower.starts_with("npx ")
192 || cmd_lower.starts_with("dotnet ")
193 || cmd_lower.starts_with("cargo ")
194 || cmd_lower.ends_with(".bat")
195 || cmd_lower.ends_with(".cmd");
196
197 if needs_call {
198 batch_content.push_str(&format!("call {}\n", cmd));
199 } else {
200 batch_content.push_str(&format!("{}\n", cmd));
201 }
202
203 batch_content.push_str(&format!("title [IRIS] {}\n", app.name));
204 i += 1;
205 }
206
207 batch_content.push_str(&format!("title [IRIS] {}\n", app.name));
208 batch_content.push_str("cmd /k\n");
209
210 batch_content
211 }
212
213 fn find_console_pid(title: &str) -> Option<u32> {
215 let search_title = format!("[IRIS] {}", title);
216
217 for _ in 0..5 {
218 #[cfg(windows)]
220 let output = Command::new("powershell")
221 .args([
222 "-NoProfile",
223 "-Command",
224 &format!(
225 "Get-Process cmd -ErrorAction SilentlyContinue | Where-Object {{$_.MainWindowTitle -eq '{}'}} | Select-Object -First 1 -ExpandProperty Id",
226 search_title
227 ),
228 ])
229 .creation_flags(CREATE_NO_WINDOW)
230 .output()
231 .ok()?;
232
233 #[cfg(not(windows))]
234 let output = Command::new("echo")
235 .arg("")
236 .output()
237 .ok()?;
238
239 let pid_str = String::from_utf8_lossy(&output.stdout);
240 if let Ok(pid) = pid_str.trim().parse() {
241 return Some(pid);
242 }
243
244 #[cfg(windows)]
246 let output = Command::new("powershell")
247 .args([
248 "-NoProfile",
249 "-Command",
250 &format!(
251 "Get-Process cmd -ErrorAction SilentlyContinue | Where-Object {{$_.MainWindowTitle -like '*[IRIS]*{}*'}} | Select-Object -First 1 -ExpandProperty Id",
252 title
253 ),
254 ])
255 .creation_flags(CREATE_NO_WINDOW)
256 .output()
257 .ok()?;
258
259 #[cfg(not(windows))]
260 let output = Command::new("echo")
261 .arg("")
262 .output()
263 .ok()?;
264
265 let pid_str = String::from_utf8_lossy(&output.stdout);
266 if let Ok(pid) = pid_str.trim().parse() {
267 return Some(pid);
268 }
269
270 std::thread::sleep(Duration::from_millis(300));
271 }
272
273 None
274 }
275
276 pub fn stop_app(&self, app_id: &str, app_name: Option<&str>, commands: Option<&Vec<String>>) {
289 let mut running = self.running_apps.lock().unwrap();
290
291 if let Some(mut process) = running.remove(app_id) {
292 if let Some(cmds) = commands {
294 for cmd in cmds {
295 #[cfg(windows)]
296 {
297 let _ = Command::new("taskkill")
298 .args(["/F", "/FI", &format!("WINDOWTITLE eq {}", cmd)])
299 .creation_flags(CREATE_NO_WINDOW)
300 .output();
301
302 let _ = Command::new("taskkill")
303 .args(["/F", "/FI", &format!("WINDOWTITLE eq {}*", cmd)])
304 .creation_flags(CREATE_NO_WINDOW)
305 .output();
306 }
307 }
308 }
309
310 if let Some(name) = app_name {
312 #[cfg(windows)]
313 let _ = Command::new("taskkill")
314 .args(["/F", "/FI", &format!("WINDOWTITLE eq [IRIS] {}", name)])
315 .creation_flags(CREATE_NO_WINDOW)
316 .output();
317 }
318
319 if let Some(pid) = process.console_pid {
321 #[cfg(windows)]
322 let _ = Command::new("taskkill")
323 .args(["/F", "/T", "/PID", &pid.to_string()])
324 .creation_flags(CREATE_NO_WINDOW)
325 .output();
326 }
327
328 #[cfg(windows)]
330 {
331 let batch_name = format!("iris_{}.bat", app_id);
332 let _ = Command::new("cmd")
333 .args(["/C", &format!(
334 "wmic process where \"CommandLine like '%{}%'\" call terminate 2>nul",
335 batch_name
336 )])
337 .creation_flags(CREATE_NO_WINDOW)
338 .output();
339 }
340
341 #[cfg(windows)]
343 for pattern in ["npm*", "node*", "vite*", "yarn*", "pnpm*"] {
344 let _ = Command::new("taskkill")
345 .args(["/F", "/FI", &format!("WINDOWTITLE eq {}", pattern)])
346 .creation_flags(CREATE_NO_WINDOW)
347 .output();
348 }
349
350 let _ = process.child.kill();
352 }
353 }
354
355 pub fn restart_app(&self, app: &AppConfig) {
359 self.stop_app(&app.id, Some(&app.name), Some(&app.commands));
360 std::thread::sleep(Duration::from_millis(200));
361 self.launch_app(app);
362 }
363
364 pub fn is_running(&self, app_id: &str) -> bool {
366 let running = self.running_apps.lock().unwrap();
367 running.contains_key(app_id)
368 }
369
370 pub fn is_loading(&self, app_id: &str) -> bool {
372 let loading = self.loading_apps.lock().unwrap();
373 loading.contains(app_id)
374 }
375
376 pub fn running_count(&self) -> usize {
378 let running = self.running_apps.lock().unwrap();
379 running.len()
380 }
381
382 pub fn has_loading(&self) -> bool {
384 let loading = self.loading_apps.lock().unwrap();
385 !loading.is_empty()
386 }
387
388 pub fn has_running(&self) -> bool {
390 let running = self.running_apps.lock().unwrap();
391 !running.is_empty()
392 }
393
394 pub fn cleanup_dead_processes(&self) {
399 let mut running = self.running_apps.lock().unwrap();
400 let mut to_remove = Vec::new();
401
402 for (app_id, process) in running.iter() {
403 if let Some(pid) = process.console_pid {
404 #[cfg(windows)]
405 let output = Command::new("tasklist")
406 .args(["/FI", &format!("PID eq {}", pid), "/NH"])
407 .output();
408
409 #[cfg(not(windows))]
410 let output = Command::new("ps")
411 .args(["-p", &pid.to_string()])
412 .output();
413
414 if let Ok(output) = output {
415 let output_str = String::from_utf8_lossy(&output.stdout);
416 #[cfg(windows)]
417 let is_dead = !output_str.to_lowercase().contains("cmd.exe");
418 #[cfg(not(windows))]
419 let is_dead = output_str.is_empty();
420
421 if is_dead {
422 to_remove.push(app_id.clone());
423 }
424 }
425 }
426 }
427
428 for app_id in to_remove {
429 running.remove(&app_id);
430 }
431 }
432}
433
434impl Default for ProcessManager {
435 fn default() -> Self {
436 Self::new()
437 }
438}