pacsea/install/
shell.rs

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"))]
7/// What: Spawn a terminal to run a `&&`-joined series of shell commands with a hold tail.
8///
9/// Input:
10/// - `cmds`: Ordered list of shell snippets to execute.
11///
12/// Output:
13/// - Starts the preferred terminal (or `bash`) running the composed command sequence.
14///
15/// Details:
16/// - Defers to `spawn_shell_commands_in_terminal_with_hold` to add the default hold tail.
17/// - During tests, this is a no-op to avoid opening real terminal windows, unless `PACSEA_TEST_OUT` is set.
18pub fn spawn_shell_commands_in_terminal(cmds: &[String]) {
19    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
20    #[cfg(test)]
21    if std::env::var("PACSEA_TEST_OUT").is_err() {
22        return;
23    }
24    // Default wrapper keeps the terminal open after commands complete
25    spawn_shell_commands_in_terminal_with_hold(cmds, true);
26}
27
28#[cfg(not(target_os = "windows"))]
29/// What: Write a log message to terminal.log file.
30///
31/// Input:
32/// - `message`: The log message to write.
33///
34/// Output:
35/// - Writes the message to terminal.log, creating the log directory if needed.
36///
37/// Details:
38/// - Silently ignores errors if the log file cannot be opened or written.
39fn 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"))]
55/// What: Configure environment variables for a terminal command based on terminal type and environment.
56///
57/// Input:
58/// - `cmd`: The Command to configure.
59/// - `term`: Terminal binary name.
60/// - `is_wayland`: Whether running under Wayland.
61///
62/// Output:
63/// - Modifies the command with appropriate environment variables.
64///
65/// Details:
66/// - Sets `PACSEA_TEST_OUT` if present in environment.
67/// - Suppresses `Konsole` `Wayland` warnings on `Wayland`.
68/// - Forces software rendering for `GNOME Console` and `kgx`.
69fn 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"))]
86/// What: Build and spawn a terminal command with logging.
87///
88/// Input:
89/// - `term`: Terminal binary name.
90/// - `args`: Terminal arguments.
91/// - `needs_xfce_command`: Whether to use xfce4-terminal special command format.
92/// - `script_exec`: The script execution command string.
93/// - `cmd_str`: The full command string for logging.
94/// - `is_wayland`: Whether running under Wayland.
95/// - `detach_stdio`: Whether to detach stdio streams.
96///
97/// Output:
98/// - Returns `Ok(true)` if spawn succeeded, `Ok(false)` if it failed, or `Err` on error.
99///
100/// Details:
101/// - Logs spawn attempt and result to terminal.log.
102/// - Configures terminal-specific environment variables.
103fn 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"))]
142/// What: Create a temporary script file with the command string.
143///
144/// Input:
145/// - `cmd_str`: The command string to write to the script.
146///
147/// Output:
148/// - Path to the created temporary script file.
149///
150/// Details:
151/// - Creates a bash script with executable permissions.
152fn 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"))]
173/// What: Persist the command string to a log file for debugging.
174///
175/// Input:
176/// - `cmd_str`: The command string to log.
177///
178/// Output:
179/// - None (writes to log file).
180fn 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"))]
190/// What: Build the list of terminal candidates with preference ordering.
191///
192/// Input:
193/// - `is_gnome`: Whether running under GNOME desktop.
194///
195/// Output:
196/// - Vector of terminal candidates with (`name`, `args`, `needs_xfce_command`) tuples.
197///
198/// Details:
199/// - Prioritizes GNOME terminals when under GNOME, otherwise uses default order.
200/// - Moves user-preferred terminal to the front if configured.
201fn 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"))]
249/// What: Attempt to spawn a terminal from the candidates list.
250///
251/// Input:
252/// - `terms_owned`: List of terminal candidates.
253/// - `script_exec`: Script execution command string.
254/// - `cmd_str`: Full command string for logging.
255/// - `is_wayland`: Whether running under Wayland.
256///
257/// Output:
258/// - `true` if a terminal was successfully spawned, `false` otherwise.
259fn 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"))]
298/// What: Spawn a terminal to execute shell commands and optionally append a hold tail.
299///
300/// Input:
301/// - `cmds`: Ordered list of shell snippets to execute.
302/// - `hold`: When `true`, keeps the terminal open after command completion.
303///
304/// Output:
305/// - Launches a terminal (or `bash`) running a temporary script that encapsulates the commands.
306///
307/// Details:
308/// - Persists the command to a temp script to avoid argument-length issues.
309/// - Prefers user-configured terminals, applies desktop-specific environment tweaks, and logs spawn attempts.
310/// - During tests, this is a no-op to avoid opening real terminal windows.
311pub fn spawn_shell_commands_in_terminal_with_hold(cmds: &[String], hold: bool) {
312    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
313    #[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    /// What: Ensure `spawn_shell_commands_in_terminal` invokes GNOME Terminal with a double-dash separator.
370    ///
371    /// Inputs:
372    /// - `cmds`: Single echo command executed via a temporary mock `gnome-terminal` script.
373    ///
374    /// Output:
375    /// - Captured argv begins with `--`, `bash`, `-lc`, confirming safe argument ordering.
376    ///
377    /// Details:
378    /// - Rewrites `PATH` to point at a fake executable that records arguments, then restores env vars
379    ///   after spawning the terminal command.
380    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        // Wait for file to be created with retries
417        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")]
441/// What: Display the intended shell command sequence on Windows where execution is unsupported.
442///
443/// Input:
444/// - `cmds`: Command fragments to present to the user.
445///
446/// Output:
447/// - Launches a `PowerShell` window (if available and command contains "DRY RUN") for dry-run simulation, or `cmd` window otherwise.
448///
449/// Details:
450/// - When commands contain "DRY RUN" and `PowerShell` is available, uses `PowerShell` to simulate the operation.
451/// - Joins commands with `&&` for readability and uses `start` to detach the window.
452pub 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    // Check if this is a dry-run operation (for downgrade, etc.)
460    let is_dry_run = msg.contains("DRY RUN");
461
462    if is_dry_run && super::utils::is_powershell_available() {
463        // Use PowerShell to simulate the operation
464        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}