1use std::process::Command;
2
3#[cfg(not(target_os = "windows"))]
4use super::utils::{choose_terminal_index_prefer_path, command_on_path, shell_single_quote};
5
6#[cfg(not(target_os = "windows"))]
7pub fn spawn_shell_commands_in_terminal(cmds: &[String]) {
19 #[cfg(test)]
21 if std::env::var("PACSEA_TEST_OUT").is_err() {
22 return;
23 }
24 spawn_shell_commands_in_terminal_with_hold(cmds, true);
26}
27
28#[cfg(not(target_os = "windows"))]
29fn log_to_terminal_log(message: &str) {
40 let mut lp = crate::theme::logs_dir();
41 lp.push("terminal.log");
42 if let Some(parent) = lp.parent() {
43 let _ = std::fs::create_dir_all(parent);
44 }
45 if let Ok(mut file) = std::fs::OpenOptions::new()
46 .create(true)
47 .append(true)
48 .open(&lp)
49 {
50 let _ = std::io::Write::write_all(&mut file, message.as_bytes());
51 }
52}
53
54#[cfg(not(target_os = "windows"))]
55fn configure_terminal_env(cmd: &mut Command, term: &str, is_wayland: bool) {
70 if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
71 if let Some(parent) = std::path::Path::new(&p).parent() {
72 let _ = std::fs::create_dir_all(parent);
73 }
74 cmd.env("PACSEA_TEST_OUT", p);
75 }
76 if term == "konsole" && is_wayland {
77 cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
78 }
79 if term == "gnome-console" || term == "kgx" {
80 cmd.env("GSK_RENDERER", "cairo");
81 cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
82 }
83}
84
85#[cfg(not(target_os = "windows"))]
86fn try_spawn_terminal(
104 term: &str,
105 args: &[&str],
106 needs_xfce_command: bool,
107 script_exec: &str,
108 cmd_str: &str,
109 is_wayland: bool,
110 detach_stdio: bool,
111) -> Result<bool, std::io::Error> {
112 let mut cmd = Command::new(term);
113 if needs_xfce_command && term == "xfce4-terminal" {
114 let quoted = shell_single_quote(script_exec);
115 cmd.arg("--command").arg(format!("bash -lc {quoted}"));
116 } else {
117 cmd.args(args.iter().copied()).arg(script_exec);
118 }
119 configure_terminal_env(&mut cmd, term, is_wayland);
120 let cmd_len = cmd_str.len();
121 log_to_terminal_log(&format!(
122 "spawn term={term} args={args:?} xfce_mode={needs_xfce_command} cmd_len={cmd_len}\n"
123 ));
124 if detach_stdio {
125 cmd.stdin(std::process::Stdio::null())
126 .stdout(std::process::Stdio::null())
127 .stderr(std::process::Stdio::null());
128 }
129 let res = cmd.spawn();
130 match &res {
131 Ok(child) => {
132 log_to_terminal_log(&format!("spawn result: ok pid={}\n", child.id()));
133 }
134 Err(e) => {
135 log_to_terminal_log(&format!("spawn result: err error={e}\n"));
136 }
137 }
138 res.map(|_| true)
139}
140
141#[cfg(not(target_os = "windows"))]
142fn create_temp_script(cmd_str: &str) -> std::path::PathBuf {
153 let mut p = std::env::temp_dir();
154 let ts = std::time::SystemTime::now()
155 .duration_since(std::time::UNIX_EPOCH)
156 .map(|d| d.as_nanos())
157 .unwrap_or(0);
158 p.push(format!("pacsea_scan_{}_{}.sh", std::process::id(), ts));
159 let _ = std::fs::write(&p, format!("#!/bin/bash\n{cmd_str}\n"));
160 #[cfg(unix)]
161 {
162 use std::os::unix::fs::PermissionsExt;
163 if let Ok(meta) = std::fs::metadata(&p) {
164 let mut perms = meta.permissions();
165 perms.set_mode(0o700);
166 let _ = std::fs::set_permissions(&p, perms);
167 }
168 }
169 p
170}
171
172#[cfg(not(target_os = "windows"))]
173fn persist_command_to_log(cmd_str: &str) {
181 let mut lp = crate::theme::logs_dir();
182 lp.push("last_terminal_cmd.log");
183 if let Some(parent) = lp.parent() {
184 let _ = std::fs::create_dir_all(parent);
185 }
186 let _ = std::fs::write(&lp, format!("{cmd_str}\n"));
187}
188
189#[cfg(not(target_os = "windows"))]
190fn build_terminal_candidates(is_gnome: bool) -> Vec<(&'static str, &'static [&'static str], bool)> {
202 let terms_gnome_first: &[(&str, &[&str], bool)] = &[
203 ("gnome-terminal", &["--", "bash", "-lc"], false),
204 ("gnome-console", &["--", "bash", "-lc"], false),
205 ("kgx", &["--", "bash", "-lc"], false),
206 ("alacritty", &["-e", "bash", "-lc"], false),
207 ("ghostty", &["-e", "bash", "-lc"], false),
208 ("kitty", &["bash", "-lc"], false),
209 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
210 ("konsole", &["-e", "bash", "-lc"], false),
211 ("xfce4-terminal", &[], true),
212 ("tilix", &["--", "bash", "-lc"], false),
213 ("mate-terminal", &["--", "bash", "-lc"], false),
214 ];
215 let terms_default: &[(&str, &[&str], bool)] = &[
216 ("alacritty", &["-e", "bash", "-lc"], false),
217 ("ghostty", &["-e", "bash", "-lc"], false),
218 ("kitty", &["bash", "-lc"], false),
219 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
220 ("gnome-terminal", &["--", "bash", "-lc"], false),
221 ("gnome-console", &["--", "bash", "-lc"], false),
222 ("kgx", &["--", "bash", "-lc"], false),
223 ("konsole", &["-e", "bash", "-lc"], false),
224 ("xfce4-terminal", &[], true),
225 ("tilix", &["--", "bash", "-lc"], false),
226 ("mate-terminal", &["--", "bash", "-lc"], false),
227 ];
228 let mut terms_owned: Vec<(&str, &[&str], bool)> = if is_gnome {
229 terms_gnome_first.to_vec()
230 } else {
231 terms_default.to_vec()
232 };
233 let preferred = crate::theme::settings()
234 .preferred_terminal
235 .trim()
236 .to_string();
237 if !preferred.is_empty()
238 && let Some(pos) = terms_owned
239 .iter()
240 .position(|(name, _, _)| *name == preferred)
241 {
242 let entry = terms_owned.remove(pos);
243 terms_owned.insert(0, entry);
244 }
245 terms_owned
246}
247
248#[cfg(not(target_os = "windows"))]
249fn attempt_terminal_spawn(
260 terms_owned: &[(&str, &[&str], bool)],
261 script_exec: &str,
262 cmd_str: &str,
263 is_wayland: bool,
264) -> bool {
265 if let Some(idx) = choose_terminal_index_prefer_path(terms_owned) {
266 let (term, args, needs_xfce_command) = terms_owned[idx];
267 return try_spawn_terminal(
268 term,
269 args,
270 needs_xfce_command,
271 script_exec,
272 cmd_str,
273 is_wayland,
274 true,
275 )
276 .unwrap_or(false);
277 }
278 for (term, args, needs_xfce_command) in terms_owned.iter().copied() {
279 if command_on_path(term)
280 && try_spawn_terminal(
281 term,
282 args,
283 needs_xfce_command,
284 script_exec,
285 cmd_str,
286 is_wayland,
287 false,
288 )
289 .unwrap_or(false)
290 {
291 return true;
292 }
293 }
294 false
295}
296
297#[cfg(not(target_os = "windows"))]
298pub fn spawn_shell_commands_in_terminal_with_hold(cmds: &[String], hold: bool) {
312 #[cfg(test)]
314 if std::env::var("PACSEA_TEST_OUT").is_err() {
315 return;
316 }
317
318 if cmds.is_empty() {
319 return;
320 }
321 let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
322 let joined = cmds.join(" && ");
323 let cmd_str = if hold {
324 format!("{joined}{hold_tail}")
325 } else {
326 joined
327 };
328 let script_path = create_temp_script(&cmd_str);
329 let script_path_str = script_path.to_string_lossy().to_string();
330 let script_exec = format!("bash {}", shell_single_quote(&script_path_str));
331
332 persist_command_to_log(&cmd_str);
333
334 let desktop_env = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
335 let is_gnome = desktop_env.to_uppercase().contains("GNOME");
336 let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
337 let terms_owned = build_terminal_candidates(is_gnome);
338
339 log_to_terminal_log(&format!(
340 "env desktop={} wayland={} script={} cmd_len={}\n",
341 desktop_env,
342 is_wayland,
343 script_path_str,
344 cmd_str.len()
345 ));
346
347 let launched = attempt_terminal_spawn(&terms_owned, &script_exec, &cmd_str, is_wayland);
348 if !launched {
349 log_to_terminal_log(&format!(
350 "spawn term=bash args={:?} cmd_len={}\n",
351 ["-lc"],
352 cmd_str.len()
353 ));
354 let res = Command::new("bash").args(["-lc", &script_exec]).spawn();
355 match &res {
356 Ok(child) => {
357 log_to_terminal_log(&format!("spawn result: ok pid={}\n", child.id()));
358 }
359 Err(e) => {
360 log_to_terminal_log(&format!("spawn result: err error={e}\n"));
361 }
362 }
363 }
364}
365
366#[cfg(all(test, not(target_os = "windows")))]
367mod tests {
368 #[test]
369 fn shell_uses_gnome_terminal_double_dash() {
381 use std::fs;
382 use std::os::unix::fs::PermissionsExt;
383 use std::path::PathBuf;
384
385 let mut dir: PathBuf = std::env::temp_dir();
386 dir.push(format!(
387 "pacsea_test_shell_gnome_{}_{}",
388 std::process::id(),
389 std::time::SystemTime::now()
390 .duration_since(std::time::UNIX_EPOCH)
391 .expect("System time is before UNIX epoch")
392 .as_nanos()
393 ));
394 fs::create_dir_all(&dir).expect("create test directory");
395 let mut out_path = dir.clone();
396 out_path.push("args.txt");
397 let mut term_path = dir.clone();
398 term_path.push("gnome-terminal");
399 let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
400 fs::write(&term_path, script.as_bytes()).expect("failed to write test terminal script");
401 let mut perms = fs::metadata(&term_path)
402 .expect("failed to read test terminal script metadata")
403 .permissions();
404 perms.set_mode(0o755);
405 fs::set_permissions(&term_path, perms)
406 .expect("failed to set test terminal script permissions");
407
408 let orig_path = std::env::var_os("PATH");
409 unsafe {
410 std::env::set_var("PATH", dir.display().to_string());
411 std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
412 }
413
414 let cmds = vec!["echo hi".to_string()];
415 super::spawn_shell_commands_in_terminal(&cmds);
416 let mut attempts = 0;
418 while !out_path.exists() && attempts < 50 {
419 std::thread::sleep(std::time::Duration::from_millis(10));
420 attempts += 1;
421 }
422 let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
423 let lines: Vec<&str> = body.lines().collect();
424 assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
425 assert_eq!(lines[0], "--");
426 assert_eq!(lines[1], "bash");
427 assert_eq!(lines[2], "-lc");
428
429 unsafe {
430 if let Some(v) = orig_path {
431 std::env::set_var("PATH", v);
432 } else {
433 std::env::remove_var("PATH");
434 }
435 std::env::remove_var("PACSEA_TEST_OUT");
436 }
437 }
438}
439
440#[cfg(target_os = "windows")]
441pub fn spawn_shell_commands_in_terminal(cmds: &[String]) {
453 let msg = if cmds.is_empty() {
454 "Nothing to run".to_string()
455 } else {
456 cmds.join(" && ")
457 };
458
459 let is_dry_run = msg.contains("DRY RUN");
461
462 if is_dry_run && super::utils::is_powershell_available() {
463 let escaped_msg = msg.replace('\'', "''");
465 let powershell_cmd = format!(
466 "Write-Host '{escaped_msg}' -ForegroundColor Yellow; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
467 );
468 let _ = Command::new("powershell.exe")
469 .args(["-NoProfile", "-Command", &powershell_cmd])
470 .spawn();
471 } else {
472 let _ = Command::new("cmd")
473 .args([
474 "/C",
475 "start",
476 "Pacsea Update",
477 "cmd",
478 "/K",
479 &format!("echo {msg}"),
480 ])
481 .spawn();
482 }
483}