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) -> Result<std::path::PathBuf, std::io::Error> {
155 use std::io::Write;
156
157 let mut last_error: Option<std::io::Error> = None;
158 for attempt in 0_u32..8 {
159 let mut p = std::env::temp_dir();
160 let ts = std::time::SystemTime::now()
161 .duration_since(std::time::UNIX_EPOCH)
162 .map_or(0, |d| d.as_nanos());
163 p.push(format!(
164 "pacsea_scan_{}_{}_{}.sh",
165 std::process::id(),
166 ts,
167 attempt
168 ));
169
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::OpenOptionsExt;
173
174 let file_res = std::fs::OpenOptions::new()
175 .write(true)
176 .create_new(true)
177 .mode(0o700)
178 .open(&p);
179
180 match file_res {
181 Ok(mut file) => {
182 let write_res = file.write_all(format!("#!/bin/bash\n{cmd_str}\n").as_bytes());
183 let flush_res = write_res.and_then(|()| file.flush());
184 if flush_res.is_ok() {
185 return Ok(p);
186 }
187 let _ = std::fs::remove_file(&p);
188 last_error = flush_res.err();
189 }
190 Err(err) => {
191 last_error = Some(err);
192 }
193 }
194 }
195
196 #[cfg(not(unix))]
197 {
198 let file_res = std::fs::OpenOptions::new()
199 .write(true)
200 .create_new(true)
201 .open(&p);
202
203 match file_res {
204 Ok(mut file) => {
205 let write_res = file.write_all(format!("#!/bin/bash\n{cmd_str}\n").as_bytes());
206 let flush_res = write_res.and_then(|()| file.flush());
207 if flush_res.is_ok() {
208 return Ok(p);
209 }
210 let _ = std::fs::remove_file(&p);
211 last_error = flush_res.err();
212 }
213 Err(err) => {
214 last_error = Some(err);
215 }
216 }
217 }
218 }
219
220 Err(last_error
221 .unwrap_or_else(|| std::io::Error::other("failed to create and write temporary script")))
222}
223
224#[cfg(not(target_os = "windows"))]
225fn persist_command_to_log(cmd_str: &str) {
237 let mut lp = crate::theme::logs_dir();
238 lp.push("last_terminal_cmd.log");
239 if let Some(parent) = lp.parent() {
240 let _ = std::fs::create_dir_all(parent);
241 }
242 persist_command_to_log_path(&lp, cmd_str);
243}
244
245#[cfg(not(target_os = "windows"))]
246fn persist_command_to_log_path(log_path: &std::path::Path, cmd_str: &str) {
259 use std::io::Write;
260 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
261
262 let redacted = redact_password_pipe_for_log(cmd_str);
263 if let Ok(mut file) = std::fs::OpenOptions::new()
264 .create(true)
265 .truncate(true)
266 .write(true)
267 .mode(0o600)
268 .open(log_path)
269 {
270 let _ = std::fs::set_permissions(log_path, std::fs::Permissions::from_mode(0o600));
271 let _ = file.write_all(format!("{redacted}\n").as_bytes());
272 }
273}
274
275#[cfg(not(target_os = "windows"))]
276#[must_use]
288fn redact_password_pipe_for_log(cmd_str: &str) -> String {
289 let marker = "printf '%s\\n' ";
290 let mut out = String::with_capacity(cmd_str.len());
291 let mut rest = cmd_str;
292
293 while let Some(start_idx) = rest.find(marker) {
294 let (before, after_start) = rest.split_at(start_idx);
295 out.push_str(before);
296 out.push_str(marker);
297 let after_marker = &after_start[marker.len()..];
298 if let Some(pipe_idx) = after_marker.find(" | ") {
299 out.push_str("'[REDACTED]'");
300 rest = &after_marker[pipe_idx..];
301 } else {
302 out.push_str(after_marker);
303 rest = "";
304 }
305 }
306
307 out.push_str(rest);
308 out
309}
310
311#[cfg(not(target_os = "windows"))]
312fn build_terminal_candidates(is_gnome: bool) -> Vec<(&'static str, &'static [&'static str], bool)> {
324 let terms_gnome_first: &[(&str, &[&str], bool)] = &[
325 ("gnome-terminal", &["--", "bash", "-lc"], false),
326 ("gnome-console", &["--", "bash", "-lc"], false),
327 ("kgx", &["--", "bash", "-lc"], false),
328 ("alacritty", &["-e", "bash", "-lc"], false),
329 ("ghostty", &["-e", "bash", "-lc"], false),
330 ("kitty", &["bash", "-lc"], false),
331 ("konsole", &["-e", "bash", "-lc"], false),
332 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
333 ("xfce4-terminal", &[], true),
334 ("tilix", &["--", "bash", "-lc"], false),
335 ("mate-terminal", &["--", "bash", "-lc"], false),
336 ];
337 let terms_default: &[(&str, &[&str], bool)] = &[
338 ("alacritty", &["-e", "bash", "-lc"], false),
339 ("ghostty", &["-e", "bash", "-lc"], false),
340 ("kitty", &["bash", "-lc"], false),
341 ("konsole", &["-e", "bash", "-lc"], false),
342 ("gnome-terminal", &["--", "bash", "-lc"], false),
343 ("gnome-console", &["--", "bash", "-lc"], false),
344 ("kgx", &["--", "bash", "-lc"], false),
345 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
346 ("xfce4-terminal", &[], true),
347 ("tilix", &["--", "bash", "-lc"], false),
348 ("mate-terminal", &["--", "bash", "-lc"], false),
349 ];
350 let mut terms_owned: Vec<(&str, &[&str], bool)> = if is_gnome {
351 terms_gnome_first.to_vec()
352 } else {
353 terms_default.to_vec()
354 };
355 let preferred = crate::theme::settings()
356 .preferred_terminal
357 .trim()
358 .to_string();
359 if !preferred.is_empty()
360 && let Some(pos) = terms_owned
361 .iter()
362 .position(|(name, _, _)| *name == preferred)
363 {
364 let entry = terms_owned.remove(pos);
365 terms_owned.insert(0, entry);
366 }
367 terms_owned
368}
369
370#[cfg(not(target_os = "windows"))]
371fn attempt_terminal_spawn(
382 terms_owned: &[(&str, &[&str], bool)],
383 script_exec: &str,
384 cmd_str: &str,
385 is_wayland: bool,
386) -> bool {
387 if let Some(idx) = choose_terminal_index_prefer_path(terms_owned) {
388 let (term, args, needs_xfce_command) = terms_owned[idx];
389 return try_spawn_terminal(
390 term,
391 args,
392 needs_xfce_command,
393 script_exec,
394 cmd_str,
395 is_wayland,
396 true,
397 )
398 .unwrap_or(false);
399 }
400 for (term, args, needs_xfce_command) in terms_owned.iter().copied() {
401 if command_on_path(term)
402 && try_spawn_terminal(
403 term,
404 args,
405 needs_xfce_command,
406 script_exec,
407 cmd_str,
408 is_wayland,
409 false,
410 )
411 .unwrap_or(false)
412 {
413 return true;
414 }
415 }
416 false
417}
418
419#[cfg(not(target_os = "windows"))]
420pub fn spawn_shell_commands_in_terminal_with_hold(cmds: &[String], hold: bool) {
434 #[cfg(test)]
436 if std::env::var("PACSEA_TEST_OUT").is_err() {
437 return;
438 }
439
440 if cmds.is_empty() {
441 return;
442 }
443 let hold_tail = "echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
444 let joined = cmds.join(" && ");
445 let cmd_str = if hold {
446 format!("{joined}\n{hold_tail}")
447 } else {
448 joined
449 };
450 let (script_exec, script_ref_for_log) = match create_temp_script(&cmd_str) {
451 Ok(script_path) => {
452 let script_path_str = script_path.to_string_lossy().to_string();
453 (
454 format!("bash {}", shell_single_quote(&script_path_str)),
455 script_path_str,
456 )
457 }
458 Err(err) => {
459 log_to_terminal_log(&format!(
460 "temp script create failed, using inline command fallback: {err}\n"
461 ));
462 (cmd_str.clone(), "<inline>".to_string())
463 }
464 };
465
466 persist_command_to_log(&cmd_str);
467
468 let desktop_env = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
469 let is_gnome = desktop_env.to_uppercase().contains("GNOME");
470 let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
471 let terms_owned = build_terminal_candidates(is_gnome);
472
473 log_to_terminal_log(&format!(
474 "env desktop={} wayland={} script={} cmd_len={}\n",
475 desktop_env,
476 is_wayland,
477 script_ref_for_log,
478 cmd_str.len()
479 ));
480
481 let launched = attempt_terminal_spawn(&terms_owned, &script_exec, &cmd_str, is_wayland);
482 if !launched {
483 log_to_terminal_log(&format!(
484 "spawn term=bash args={:?} cmd_len={}\n",
485 ["-lc"],
486 cmd_str.len()
487 ));
488 let res = Command::new("bash").args(["-lc", &script_exec]).spawn();
489 match &res {
490 Ok(child) => {
491 log_to_terminal_log(&format!("spawn result: ok pid={}\n", child.id()));
492 }
493 Err(e) => {
494 log_to_terminal_log(&format!("spawn result: err error={e}\n"));
495 }
496 }
497 }
498}
499
500#[cfg(all(test, not(target_os = "windows")))]
501mod tests {
502 #[test]
503 fn shell_uses_gnome_terminal_double_dash() {
515 use std::fs;
516 use std::os::unix::fs::PermissionsExt;
517 use std::path::PathBuf;
518
519 let mut dir: PathBuf = std::env::temp_dir();
520 dir.push(format!(
521 "pacsea_test_shell_gnome_{}_{}",
522 std::process::id(),
523 std::time::SystemTime::now()
524 .duration_since(std::time::UNIX_EPOCH)
525 .expect("System time is before UNIX epoch")
526 .as_nanos()
527 ));
528 fs::create_dir_all(&dir).expect("create test directory");
529 let mut out_path = dir.clone();
530 out_path.push("args.txt");
531 let mut term_path = dir.clone();
532 term_path.push("gnome-terminal");
533 let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
534 fs::write(&term_path, script.as_bytes()).expect("failed to write test terminal script");
535 let mut perms = fs::metadata(&term_path)
536 .expect("failed to read test terminal script metadata")
537 .permissions();
538 perms.set_mode(0o755);
539 fs::set_permissions(&term_path, perms)
540 .expect("failed to set test terminal script permissions");
541
542 let orig_path = std::env::var_os("PATH");
543 unsafe {
544 std::env::set_var("PATH", dir.display().to_string());
545 std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
546 }
547
548 let cmds = vec!["echo hi".to_string()];
549 super::spawn_shell_commands_in_terminal(&cmds);
550 let mut attempts = 0;
552 while !out_path.exists() && attempts < 50 {
553 std::thread::sleep(std::time::Duration::from_millis(10));
554 attempts += 1;
555 }
556 let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
557 let lines: Vec<&str> = body.lines().collect();
558 assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
559 assert_eq!(lines[0], "--");
560 assert_eq!(lines[1], "bash");
561 assert_eq!(lines[2], "-lc");
562
563 unsafe {
564 if let Some(v) = orig_path {
565 std::env::set_var("PATH", v);
566 } else {
567 std::env::remove_var("PATH");
568 }
569 std::env::remove_var("PACSEA_TEST_OUT");
570 }
571 }
572
573 #[test]
574 fn shell_redacts_password_pipe_for_log() {
585 let input =
586 "printf '%s\\n' 'pa'\"'\"'ss' | sudo -S pacman -S --noconfirm 'ripgrep' && echo done";
587 let redacted = super::redact_password_pipe_for_log(input);
588 assert!(redacted.contains("printf '%s\\n' '[REDACTED]' | sudo -S"));
589 assert!(!redacted.contains("pa'\"'\"'ss"));
590 assert!(redacted.contains("pacman -S --noconfirm 'ripgrep' && echo done"));
591 }
592
593 #[test]
594 fn shell_persist_command_log_forces_mode_on_existing_file() {
606 use std::fs;
607 use std::os::unix::fs::PermissionsExt;
608
609 let mut dir = std::env::temp_dir();
610 dir.push(format!(
611 "pacsea_test_shell_log_mode_{}_{}",
612 std::process::id(),
613 std::time::SystemTime::now()
614 .duration_since(std::time::UNIX_EPOCH)
615 .expect("System time is before UNIX epoch")
616 .as_nanos()
617 ));
618 fs::create_dir_all(&dir).expect("create test directory");
619 let log_path = dir.join("last_terminal_cmd.log");
620
621 fs::write(&log_path, b"old").expect("create test log file");
622 let mut perms = fs::metadata(&log_path)
623 .expect("read test log metadata")
624 .permissions();
625 perms.set_mode(0o644);
626 fs::set_permissions(&log_path, perms).expect("set broad test log permissions");
627
628 super::persist_command_to_log_path(&log_path, "echo test");
629
630 let mode = fs::metadata(&log_path)
631 .expect("read updated log metadata")
632 .permissions()
633 .mode()
634 & 0o777;
635 assert_eq!(mode, 0o600);
636
637 let _ = fs::remove_file(&log_path);
638 let _ = fs::remove_dir(&dir);
639 }
640
641 #[test]
642 fn create_temp_script_returns_error_when_open_fails() {
653 let original_tmpdir = std::env::var_os("TMPDIR");
654 let mut invalid_tmp = std::env::temp_dir();
655 invalid_tmp.push(format!(
656 "pacsea_missing_tmp_parent_{}/nested",
657 std::time::SystemTime::now()
658 .duration_since(std::time::UNIX_EPOCH)
659 .expect("System time is before UNIX epoch")
660 .as_nanos()
661 ));
662 unsafe {
663 std::env::set_var("TMPDIR", invalid_tmp.display().to_string());
664 }
665
666 let result = super::create_temp_script("echo hello");
667 assert!(result.is_err(), "expected temp script creation failure");
668
669 unsafe {
670 if let Some(v) = original_tmpdir {
671 std::env::set_var("TMPDIR", v);
672 } else {
673 std::env::remove_var("TMPDIR");
674 }
675 }
676 }
677
678 #[test]
679 fn shell_hold_tail_for_multiline_script_has_no_leading_semicolon_line() {
690 use std::fs;
691 use std::os::unix::fs::PermissionsExt;
692 use std::path::PathBuf;
693
694 let mut dir: PathBuf = std::env::temp_dir();
695 dir.push(format!(
696 "pacsea_test_shell_hold_tail_{}_{}",
697 std::process::id(),
698 std::time::SystemTime::now()
699 .duration_since(std::time::UNIX_EPOCH)
700 .expect("System time is before UNIX epoch")
701 .as_nanos()
702 ));
703 fs::create_dir_all(&dir).expect("create test directory");
704 let out_path = dir.join("args.txt");
705 let term_path = dir.join("gnome-terminal");
706 let recorder = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
707 fs::write(&term_path, recorder.as_bytes()).expect("write fake gnome-terminal");
708 let mut perms = fs::metadata(&term_path)
709 .expect("read fake terminal metadata")
710 .permissions();
711 perms.set_mode(0o755);
712 fs::set_permissions(&term_path, perms).expect("set fake terminal executable");
713
714 let orig_path = std::env::var_os("PATH");
715 unsafe {
716 std::env::set_var("PATH", dir.display().to_string());
717 std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
718 }
719
720 let cmds = vec!["set -e\nprintf 'hello\\n'".to_string()];
721 super::spawn_shell_commands_in_terminal_with_hold(&cmds, true);
722
723 let mut attempts = 0;
724 while !out_path.exists() && attempts < 50 {
725 std::thread::sleep(std::time::Duration::from_millis(10));
726 attempts += 1;
727 }
728 let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
729 let lines: Vec<&str> = body.lines().collect();
730 assert!(lines.len() >= 4, "expected at least 4 args, got: {body}");
731 let script_exec = lines[3];
732 let script_path = script_exec
733 .split('\'')
734 .nth(1)
735 .expect("script path quoted in bash invocation");
736 let generated = fs::read_to_string(script_path).expect("read generated temp script");
737 assert!(
738 !generated
739 .lines()
740 .any(|line| line.trim_start().starts_with(';')),
741 "generated script must not include leading semicolon lines: {generated}"
742 );
743
744 unsafe {
745 if let Some(v) = orig_path {
746 std::env::set_var("PATH", v);
747 } else {
748 std::env::remove_var("PATH");
749 }
750 std::env::remove_var("PACSEA_TEST_OUT");
751 }
752 }
753}
754
755#[cfg(target_os = "windows")]
756pub fn spawn_shell_commands_in_terminal(cmds: &[String]) {
768 let msg = if cmds.is_empty() {
769 "Nothing to run".to_string()
770 } else {
771 cmds.join(" && ")
772 };
773
774 let is_dry_run = msg.contains("DRY RUN");
776
777 if is_dry_run && super::utils::is_powershell_available() {
778 let escaped_msg = msg.replace('\'', "''");
780 let powershell_cmd = format!(
781 "Write-Host '{escaped_msg}' -ForegroundColor Yellow; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
782 );
783 let _ = Command::new("powershell.exe")
784 .args(["-NoProfile", "-Command", &powershell_cmd])
785 .spawn();
786 } else {
787 let _ = Command::new("cmd")
788 .args([
789 "/C",
790 "start",
791 "Pacsea Update",
792 "cmd",
793 "/K",
794 &super::utils::cmd_echo_command(&msg),
795 ])
796 .spawn();
797 }
798}