1#[cfg(not(target_os = "windows"))]
2use crate::state::Source;
3#[allow(unused_imports)]
4use std::process::Command;
5
6use crate::state::PackageItem;
7
8#[cfg(not(target_os = "windows"))]
9use super::command::aur_install_body;
10#[cfg(not(target_os = "windows"))]
11use super::logging::log_installed;
12#[cfg(not(target_os = "windows"))]
13use super::utils::{choose_terminal_index_prefer_path, command_on_path, shell_single_quote};
14
15#[cfg(not(target_os = "windows"))]
16fn build_batch_install_command(
32 items: &[PackageItem],
33 official: &[String],
34 aur: &[String],
35 dry_run: bool,
36) -> String {
37 let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
38
39 if dry_run {
40 if !aur.is_empty() {
41 let all: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
42 let cmd = format!(
43 "(paru -S --needed --noconfirm {n} || yay -S --needed --noconfirm {n}){hold}",
44 n = all.join(" "),
45 hold = hold_tail
46 );
47 let quoted = shell_single_quote(&cmd);
48 format!("echo DRY RUN: {quoted}")
49 } else if !official.is_empty() {
50 let cmd = format!(
51 "sudo pacman -S --needed --noconfirm {n}{hold}",
52 n = official.join(" "),
53 hold = hold_tail
54 );
55 let quoted = shell_single_quote(&cmd);
56 format!("echo DRY RUN: {quoted}")
57 } else {
58 format!("echo DRY RUN: nothing to install{hold_tail}")
59 }
60 } else if !aur.is_empty() {
61 let all: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
62 let n = all.join(" ");
63 format!(
64 "{body}{hold}",
65 body = aur_install_body("-S --needed --noconfirm", &n),
66 hold = hold_tail
67 )
68 } else if !official.is_empty() {
69 let has_versions = items
71 .iter()
72 .any(|item| matches!(item.source, Source::Official { .. }) && !item.version.is_empty());
73 let reinstall_any = items.iter().any(|item| {
74 matches!(item.source, Source::Official { .. }) && crate::index::is_installed(&item.name)
75 });
76
77 if has_versions && reinstall_any {
78 format!(
80 "sudo bash -c 'pacman -Sy --noconfirm && pacman -S --noconfirm {n}'{hold}",
81 n = official.join(" "),
82 hold = hold_tail
83 )
84 } else {
85 format!(
86 "sudo pacman -S --needed --noconfirm {n}{hold}",
87 n = official.join(" "),
88 hold = hold_tail
89 )
90 }
91 } else {
92 format!("echo nothing to install{hold_tail}")
93 }
94}
95
96#[cfg(not(target_os = "windows"))]
97fn try_spawn_terminal(
112 term: &str,
113 args: &[&str],
114 needs_xfce_command: bool,
115 cmd_str: &str,
116) -> Result<(), ()> {
117 let mut cmd = Command::new(term);
118 if needs_xfce_command && term == "xfce4-terminal" {
119 let quoted = shell_single_quote(cmd_str);
120 cmd.arg("--command").arg(format!("bash -lc {quoted}"));
121 } else {
122 cmd.args(args.iter().copied()).arg(cmd_str);
123 }
124 if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
125 if let Some(parent) = std::path::Path::new(&p).parent() {
126 let _ = std::fs::create_dir_all(parent);
127 }
128 cmd.env("PACSEA_TEST_OUT", p);
129 }
130 if term == "konsole" && std::env::var_os("WAYLAND_DISPLAY").is_some() {
131 cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
132 }
133 if term == "gnome-console" || term == "kgx" {
134 cmd.env("GSK_RENDERER", "cairo");
135 cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
136 }
137 cmd.spawn().map(|_| ()).map_err(|_| ())
138}
139
140#[cfg(not(target_os = "windows"))]
141pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
157 #[cfg(test)]
159 if std::env::var("PACSEA_TEST_OUT").is_err() {
160 return;
161 }
162
163 let mut official: Vec<String> = Vec::new();
164 let mut aur: Vec<String> = Vec::new();
165 for it in items {
166 match it.source {
167 Source::Official { .. } => official.push(it.name.clone()),
168 Source::Aur => aur.push(it.name.clone()),
169 }
170 }
171 let names_vec: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
172 tracing::info!(
173 total = items.len(),
174 aur_count = aur.len(),
175 official_count = official.len(),
176 dry_run = dry_run,
177 names = %names_vec.join(" "),
178 "spawning install"
179 );
180
181 let cmd_str = build_batch_install_command(items, &official, &aur, dry_run);
182
183 let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
185 .ok()
186 .is_some_and(|v| v.to_uppercase().contains("GNOME"));
187 let terms_gnome_first: &[(&str, &[&str], bool)] = &[
188 ("gnome-terminal", &["--", "bash", "-lc"], false),
189 ("gnome-console", &["--", "bash", "-lc"], false),
190 ("kgx", &["--", "bash", "-lc"], false),
191 ("alacritty", &["-e", "bash", "-lc"], false),
192 ("kitty", &["bash", "-lc"], false),
193 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
194 ("konsole", &["-e", "bash", "-lc"], false),
195 ("xfce4-terminal", &[], true),
196 ("tilix", &["--", "bash", "-lc"], false),
197 ("mate-terminal", &["--", "bash", "-lc"], false),
198 ];
199 let terms_default: &[(&str, &[&str], bool)] = &[
200 ("alacritty", &["-e", "bash", "-lc"], false),
201 ("kitty", &["bash", "-lc"], false),
202 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
203 ("gnome-terminal", &["--", "bash", "-lc"], false),
204 ("gnome-console", &["--", "bash", "-lc"], false),
205 ("kgx", &["--", "bash", "-lc"], false),
206 ("konsole", &["-e", "bash", "-lc"], false),
207 ("xfce4-terminal", &[], true),
208 ("tilix", &["--", "bash", "-lc"], false),
209 ("mate-terminal", &["--", "bash", "-lc"], false),
210 ];
211 let terms = if is_gnome {
212 terms_gnome_first
213 } else {
214 terms_default
215 };
216 let mut launched = false;
217 if let Some(idx) = choose_terminal_index_prefer_path(terms) {
218 let (term, args, needs_xfce_command) = terms[idx];
219 match try_spawn_terminal(term, args, needs_xfce_command, &cmd_str) {
220 Ok(()) => {
221 tracing::info!(terminal = %term, total = items.len(), aur_count = aur.len(), official_count = official.len(), dry_run = dry_run, names = %names_vec.join(" "), "launched terminal for install");
222 launched = true;
223 }
224 Err(()) => {
225 tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
226 }
227 }
228 }
229
230 if !launched {
231 for (term, args, needs_xfce_command) in terms {
232 if command_on_path(term) {
233 match try_spawn_terminal(term, args, *needs_xfce_command, &cmd_str) {
234 Ok(()) => {
235 tracing::info!(terminal = %term, total = items.len(), aur_count = aur.len(), official_count = official.len(), dry_run = dry_run, names = %names_vec.join(" "), "launched terminal for install");
236 launched = true;
237 break;
238 }
239 Err(()) => {
240 tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
241 }
242 }
243 }
244 }
245 }
246 if !launched {
247 let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
248 if let Err(e) = res {
249 tracing::error!(error = %e, names = %names_vec.join(" "), "failed to spawn bash to run install command");
250 } else {
251 tracing::info!(total = items.len(), aur_count = aur.len(), official_count = official.len(), dry_run = dry_run, names = %names_vec.join(" "), "launched bash for install");
252 }
253 }
254
255 if !dry_run {
256 let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
257 if !names.is_empty()
258 && let Err(e) = log_installed(&names)
259 {
260 tracing::warn!(error = %e, count = names.len(), "failed to write install audit log");
261 }
262 }
263}
264
265#[cfg(all(test, not(target_os = "windows")))]
266mod tests {
267 #[test]
268 fn install_batch_uses_gnome_terminal_double_dash() {
280 use std::fs;
281 use std::os::unix::fs::PermissionsExt;
282 use std::path::PathBuf;
283
284 let mut dir: PathBuf = std::env::temp_dir();
285 dir.push(format!(
286 "pacsea_test_inst_batch_gnome_{}_{}",
287 std::process::id(),
288 std::time::SystemTime::now()
289 .duration_since(std::time::UNIX_EPOCH)
290 .expect("System time is before UNIX epoch")
291 .as_nanos()
292 ));
293 let _ = fs::create_dir_all(&dir);
294 let mut out_path = dir.clone();
295 out_path.push("args.txt");
296 let mut term_path = dir.clone();
297 term_path.push("gnome-terminal");
298 let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
299 fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
300 let mut perms = fs::metadata(&term_path)
301 .expect("Failed to read test terminal script metadata")
302 .permissions();
303 perms.set_mode(0o755);
304 fs::set_permissions(&term_path, perms)
305 .expect("Failed to set test terminal script permissions");
306
307 let orig_path = std::env::var_os("PATH");
308 unsafe {
309 std::env::set_var("PATH", dir.display().to_string());
310 std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
311 }
312
313 let items = vec![
314 crate::state::PackageItem {
315 name: "rg".into(),
316 version: "1".into(),
317 description: String::new(),
318 source: crate::state::Source::Official {
319 repo: "extra".into(),
320 arch: "x86_64".into(),
321 },
322 popularity: None,
323 out_of_date: None,
324 orphaned: false,
325 },
326 crate::state::PackageItem {
327 name: "fd".into(),
328 version: "1".into(),
329 description: String::new(),
330 source: crate::state::Source::Official {
331 repo: "extra".into(),
332 arch: "x86_64".into(),
333 },
334 popularity: None,
335 out_of_date: None,
336 orphaned: false,
337 },
338 ];
339 super::spawn_install_all(&items, true);
340 std::thread::sleep(std::time::Duration::from_millis(50));
341
342 let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
343 let lines: Vec<&str> = body.lines().collect();
344 assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
345 assert_eq!(lines[0], "--");
346 assert_eq!(lines[1], "bash");
347 assert_eq!(lines[2], "-lc");
348
349 unsafe {
350 if let Some(v) = orig_path {
351 std::env::set_var("PATH", v);
352 } else {
353 std::env::remove_var("PATH");
354 }
355 std::env::remove_var("PACSEA_TEST_OUT");
356 }
357 }
358}
359
360#[cfg(target_os = "windows")]
361#[allow(unused_variables, clippy::missing_const_for_fn)]
375pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
376 #[cfg(not(test))]
377 {
378 let mut names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
379 if names.is_empty() {
380 names.push("nothing".into());
381 }
382 let names_str = names.join(" ");
383
384 if dry_run && super::utils::is_powershell_available() {
385 let powershell_cmd = format!(
387 "Write-Host 'DRY RUN: Simulating batch install of {}' -ForegroundColor Yellow; Write-Host 'Packages: {}' -ForegroundColor Cyan; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')",
388 names.len(),
389 names_str.replace('\'', "''")
390 );
391 let _ = Command::new("powershell.exe")
392 .args(["-NoProfile", "-Command", &powershell_cmd])
393 .spawn();
394 } else {
395 let msg = if dry_run {
396 format!("DRY RUN: install {names_str}")
397 } else {
398 format!("Install {names_str} (not supported on Windows)")
399 };
400 let _ = Command::new("cmd")
401 .args([
402 "/C",
403 "start",
404 "Pacsea Install",
405 "cmd",
406 "/K",
407 &format!("echo {msg}"),
408 ])
409 .spawn();
410 }
411
412 if !dry_run {
413 let _ = super::logging::log_installed(&names);
414 }
415 }
416}