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, aur_install_helper_flags};
10#[cfg(not(target_os = "windows"))]
11use super::logging::log_installed;
12#[cfg(not(target_os = "windows"))]
13use super::utils::{
14 choose_terminal_index_prefer_path, command_on_path, shell_single_quote, validate_package_names,
15};
16
17#[cfg(not(target_os = "windows"))]
18fn build_batch_install_command(
38 items: &[PackageItem],
39 official: &[String],
40 aur: &[String],
41 dry_run: bool,
42) -> Result<String, String> {
43 validate_package_names(official, "batch install command (official)")?;
44 validate_package_names(aur, "batch install command (AUR)")?;
45 let official_quoted: Vec<String> = official
46 .iter()
47 .map(|name| shell_single_quote(name))
48 .collect();
49 let aur_quoted: Vec<String> = aur.iter().map(|name| shell_single_quote(name)).collect();
50 let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
51
52 let installed_set = crate::logic::deps::get_installed_packages();
53 let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
54
55 let official_has_reinstall = official.iter().any(|name| {
56 crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
57 });
58 let pacman_dry_flags = if official_has_reinstall {
59 "--noconfirm"
60 } else {
61 "--needed --noconfirm"
62 };
63
64 let aur_has_reinstall = aur.iter().any(|name| {
65 crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
66 });
67 let aur_s_flags = aur_install_helper_flags(aur_has_reinstall);
68 let aur_cli_suffix = if aur_has_reinstall {
69 "--noconfirm"
70 } else {
71 "--needed --noconfirm"
72 };
73
74 if dry_run {
75 if !aur.is_empty() && !official.is_empty() {
76 let tool = crate::logic::privilege::active_tool()?;
77 let off_cmd = crate::logic::privilege::build_privilege_command(
78 tool,
79 &format!("pacman -S {pacman_dry_flags} {}", official_quoted.join(" ")),
80 );
81 let cmd = format!(
82 "{off_cmd} && (paru -S --aur {aur_cli_suffix} {n} || yay -S --aur {aur_cli_suffix} {n}){hold}",
83 n = aur_quoted.join(" "),
84 hold = hold_tail
85 );
86 let quoted = shell_single_quote(&cmd);
87 Ok(format!("echo DRY RUN: {quoted}"))
88 } else if !aur.is_empty() {
89 let cmd = format!(
90 "(paru -S --aur {aur_cli_suffix} {n} || yay -S --aur {aur_cli_suffix} {n}){hold}",
91 n = aur_quoted.join(" "),
92 hold = hold_tail
93 );
94 let quoted = shell_single_quote(&cmd);
95 Ok(format!("echo DRY RUN: {quoted}"))
96 } else if !official.is_empty() {
97 let tool = crate::logic::privilege::active_tool()?;
98 let cmd = format!(
99 "{}{hold}",
100 crate::logic::privilege::build_privilege_command(
101 tool,
102 &format!("pacman -S {pacman_dry_flags} {}", official_quoted.join(" "))
103 ),
104 hold = hold_tail
105 );
106 let quoted = shell_single_quote(&cmd);
107 Ok(format!("echo DRY RUN: {quoted}"))
108 } else {
109 Ok(format!("echo DRY RUN: nothing to install{hold_tail}"))
110 }
111 } else if !aur.is_empty() && !official.is_empty() {
112 let has_versions = items
113 .iter()
114 .any(|item| matches!(item.source, Source::Official { .. }) && !item.version.is_empty());
115 let reinstall_any = items.iter().any(|item| {
116 matches!(item.source, Source::Official { .. }) && crate::index::is_installed(&item.name)
117 });
118
119 let tool = crate::logic::privilege::active_tool()?;
120 let aur_body = aur_install_body(aur_s_flags, &aur_quoted.join(" "));
121 if has_versions && reinstall_any {
122 Ok(format!(
123 "{} bash -c 'pacman -Sy --noconfirm && pacman -S --noconfirm {n}' && {aur_body}{hold}",
124 tool.binary_name(),
125 n = official_quoted.join(" "),
126 aur_body = aur_body,
127 hold = hold_tail
128 ))
129 } else {
130 Ok(format!(
131 "{} && {aur_body}{hold}",
132 crate::logic::privilege::build_privilege_command(
133 tool,
134 &format!(
135 "pacman -S --needed --noconfirm {}",
136 official_quoted.join(" ")
137 )
138 ),
139 aur_body = aur_body,
140 hold = hold_tail
141 ))
142 }
143 } else if !aur.is_empty() {
144 Ok(format!(
145 "{body}{hold}",
146 body = aur_install_body(aur_s_flags, &aur_quoted.join(" ")),
147 hold = hold_tail
148 ))
149 } else if !official.is_empty() {
150 let has_versions = items
152 .iter()
153 .any(|item| matches!(item.source, Source::Official { .. }) && !item.version.is_empty());
154 let reinstall_any = items.iter().any(|item| {
155 matches!(item.source, Source::Official { .. }) && crate::index::is_installed(&item.name)
156 });
157
158 let tool = crate::logic::privilege::active_tool()?;
159 if has_versions && reinstall_any {
160 Ok(format!(
161 "{} bash -c 'pacman -Sy --noconfirm && pacman -S --noconfirm {n}'{hold}",
162 tool.binary_name(),
163 n = official_quoted.join(" "),
164 hold = hold_tail
165 ))
166 } else {
167 Ok(format!(
168 "{}{hold}",
169 crate::logic::privilege::build_privilege_command(
170 tool,
171 &format!(
172 "pacman -S --needed --noconfirm {}",
173 official_quoted.join(" ")
174 )
175 ),
176 hold = hold_tail
177 ))
178 }
179 } else {
180 Ok(format!("echo nothing to install{hold_tail}"))
181 }
182}
183
184#[cfg(not(target_os = "windows"))]
185fn try_spawn_terminal(
200 term: &str,
201 args: &[&str],
202 needs_xfce_command: bool,
203 cmd_str: &str,
204) -> Result<(), ()> {
205 let mut cmd = Command::new(term);
206 if needs_xfce_command && term == "xfce4-terminal" {
207 let quoted = shell_single_quote(cmd_str);
208 cmd.arg("--command").arg(format!("bash -lc {quoted}"));
209 } else {
210 cmd.args(args.iter().copied()).arg(cmd_str);
211 }
212 if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
213 if let Some(parent) = std::path::Path::new(&p).parent() {
214 let _ = std::fs::create_dir_all(parent);
215 }
216 cmd.env("PACSEA_TEST_OUT", p);
217 }
218 if term == "konsole" && std::env::var_os("WAYLAND_DISPLAY").is_some() {
219 cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
220 }
221 if term == "gnome-console" || term == "kgx" {
222 cmd.env("GSK_RENDERER", "cairo");
223 cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
224 }
225 cmd.spawn().map(|_| ()).map_err(|_| ())
226}
227
228#[cfg(not(target_os = "windows"))]
229pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
245 #[cfg(test)]
247 if std::env::var("PACSEA_TEST_OUT").is_err() {
248 return;
249 }
250
251 let mut official: Vec<String> = Vec::new();
252 let mut aur: Vec<String> = Vec::new();
253 for it in items {
254 match it.source {
255 Source::Official { .. } => official.push(it.name.clone()),
256 Source::Aur => aur.push(it.name.clone()),
257 }
258 }
259 let names_vec: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
260 tracing::info!(
261 total = items.len(),
262 aur_count = aur.len(),
263 official_count = official.len(),
264 dry_run = dry_run,
265 names = %names_vec.join(" "),
266 "spawning install"
267 );
268
269 let cmd_str = match build_batch_install_command(items, &official, &aur, dry_run) {
270 Ok(s) => s,
271 Err(err) => {
272 tracing::error!(error = %err, "privilege tool resolution failed for batch install");
273 return;
274 }
275 };
276
277 let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
279 .ok()
280 .is_some_and(|v| v.to_uppercase().contains("GNOME"));
281 let terms_gnome_first: &[(&str, &[&str], bool)] = &[
282 ("gnome-terminal", &["--", "bash", "-lc"], false),
283 ("gnome-console", &["--", "bash", "-lc"], false),
284 ("kgx", &["--", "bash", "-lc"], false),
285 ("alacritty", &["-e", "bash", "-lc"], false),
286 ("kitty", &["bash", "-lc"], false),
287 ("konsole", &["-e", "bash", "-lc"], false),
288 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
289 ("xfce4-terminal", &[], true),
290 ("tilix", &["--", "bash", "-lc"], false),
291 ("mate-terminal", &["--", "bash", "-lc"], false),
292 ];
293 let terms_default: &[(&str, &[&str], bool)] = &[
294 ("alacritty", &["-e", "bash", "-lc"], false),
295 ("kitty", &["bash", "-lc"], false),
296 ("konsole", &["-e", "bash", "-lc"], false),
297 ("gnome-terminal", &["--", "bash", "-lc"], false),
298 ("gnome-console", &["--", "bash", "-lc"], false),
299 ("kgx", &["--", "bash", "-lc"], false),
300 ("xterm", &["-hold", "-e", "bash", "-lc"], false),
301 ("xfce4-terminal", &[], true),
302 ("tilix", &["--", "bash", "-lc"], false),
303 ("mate-terminal", &["--", "bash", "-lc"], false),
304 ];
305 let terms = if is_gnome {
306 terms_gnome_first
307 } else {
308 terms_default
309 };
310 let mut launched = false;
311 if let Some(idx) = choose_terminal_index_prefer_path(terms) {
312 let (term, args, needs_xfce_command) = terms[idx];
313 match try_spawn_terminal(term, args, needs_xfce_command, &cmd_str) {
314 Ok(()) => {
315 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");
316 launched = true;
317 }
318 Err(()) => {
319 tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
320 }
321 }
322 }
323
324 if !launched {
325 for (term, args, needs_xfce_command) in terms {
326 if command_on_path(term) {
327 match try_spawn_terminal(term, args, *needs_xfce_command, &cmd_str) {
328 Ok(()) => {
329 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");
330 launched = true;
331 break;
332 }
333 Err(()) => {
334 tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
335 }
336 }
337 }
338 }
339 }
340 if !launched {
341 let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
342 if let Err(e) = res {
343 tracing::error!(error = %e, names = %names_vec.join(" "), "failed to spawn bash to run install command");
344 } else {
345 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");
346 }
347 }
348
349 if !dry_run {
350 let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
351 if !names.is_empty()
352 && let Err(e) = log_installed(&names)
353 {
354 tracing::warn!(error = %e, count = names.len(), "failed to write install audit log");
355 }
356 }
357}
358
359#[cfg(all(test, not(target_os = "windows")))]
360mod tests {
361 #[test]
362 fn install_batch_uses_gnome_terminal_double_dash() {
374 use std::fs;
375 use std::os::unix::fs::PermissionsExt;
376 use std::path::PathBuf;
377
378 let mut dir: PathBuf = std::env::temp_dir();
379 dir.push(format!(
380 "pacsea_test_inst_batch_gnome_{}_{}",
381 std::process::id(),
382 std::time::SystemTime::now()
383 .duration_since(std::time::UNIX_EPOCH)
384 .expect("System time is before UNIX epoch")
385 .as_nanos()
386 ));
387 let _ = fs::create_dir_all(&dir);
388 let mut out_path = dir.clone();
389 out_path.push("args.txt");
390 let mut term_path = dir.clone();
391 term_path.push("gnome-terminal");
392 let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
393 fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
394 let mut perms = fs::metadata(&term_path)
395 .expect("Failed to read test terminal script metadata")
396 .permissions();
397 perms.set_mode(0o755);
398 fs::set_permissions(&term_path, perms)
399 .expect("Failed to set test terminal script permissions");
400
401 let orig_path = std::env::var_os("PATH");
402 unsafe {
403 std::env::set_var("PATH", dir.display().to_string());
404 std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
405 }
406
407 let items = vec![
408 crate::state::PackageItem {
409 name: "rg".into(),
410 version: "1".into(),
411 description: String::new(),
412 source: crate::state::Source::Official {
413 repo: "extra".into(),
414 arch: "x86_64".into(),
415 },
416 popularity: None,
417 out_of_date: None,
418 orphaned: false,
419 },
420 crate::state::PackageItem {
421 name: "fd".into(),
422 version: "1".into(),
423 description: String::new(),
424 source: crate::state::Source::Official {
425 repo: "extra".into(),
426 arch: "x86_64".into(),
427 },
428 popularity: None,
429 out_of_date: None,
430 orphaned: false,
431 },
432 ];
433 super::spawn_install_all(&items, true);
434 std::thread::sleep(std::time::Duration::from_millis(50));
435
436 let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
437 let lines: Vec<&str> = body.lines().collect();
438 assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
439 assert_eq!(lines[0], "--");
440 assert_eq!(lines[1], "bash");
441 assert_eq!(lines[2], "-lc");
442
443 unsafe {
444 if let Some(v) = orig_path {
445 std::env::set_var("PATH", v);
446 } else {
447 std::env::remove_var("PATH");
448 }
449 std::env::remove_var("PACSEA_TEST_OUT");
450 }
451 }
452}
453
454#[cfg(target_os = "windows")]
455#[allow(unused_variables, clippy::missing_const_for_fn)]
469pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
470 #[cfg(not(test))]
471 {
472 let mut names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
473 if names.is_empty() {
474 names.push("nothing".into());
475 }
476 let names_str = names.join(" ");
477
478 if dry_run && super::utils::is_powershell_available() {
479 let powershell_cmd = format!(
481 "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')",
482 names.len(),
483 names_str.replace('\'', "''")
484 );
485 let _ = Command::new("powershell.exe")
486 .args(["-NoProfile", "-Command", &powershell_cmd])
487 .spawn();
488 } else {
489 let msg = if dry_run {
490 format!("DRY RUN: install {names_str}")
491 } else {
492 format!("Install {names_str} (not supported on Windows)")
493 };
494 let _ = Command::new("cmd")
495 .args([
496 "/C",
497 "start",
498 "Pacsea Install",
499 "cmd",
500 "/K",
501 &super::utils::cmd_echo_command(&msg),
502 ])
503 .spawn();
504 }
505
506 if !dry_run {
507 let _ = super::logging::log_installed(&names);
508 }
509 }
510}