#[cfg(not(target_os = "windows"))]
use crate::state::Source;
#[allow(unused_imports)]
use std::process::Command;
use crate::state::PackageItem;
#[cfg(not(target_os = "windows"))]
use super::command::{aur_install_body, aur_install_helper_flags};
#[cfg(not(target_os = "windows"))]
use super::logging::log_installed;
#[cfg(not(target_os = "windows"))]
use super::utils::{
choose_terminal_index_prefer_path, command_on_path, shell_single_quote, validate_package_names,
};
#[cfg(not(target_os = "windows"))]
fn build_batch_install_command(
items: &[PackageItem],
official: &[String],
aur: &[String],
dry_run: bool,
) -> Result<String, String> {
validate_package_names(official, "batch install command (official)")?;
validate_package_names(aur, "batch install command (AUR)")?;
let official_quoted: Vec<String> = official
.iter()
.map(|name| shell_single_quote(name))
.collect();
let aur_quoted: Vec<String> = aur.iter().map(|name| shell_single_quote(name)).collect();
let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
let installed_set = crate::logic::deps::get_installed_packages();
let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
let official_has_reinstall = official.iter().any(|name| {
crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
});
let pacman_dry_flags = if official_has_reinstall {
"--noconfirm"
} else {
"--needed --noconfirm"
};
let aur_has_reinstall = aur.iter().any(|name| {
crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
});
let aur_s_flags = aur_install_helper_flags(aur_has_reinstall);
let aur_cli_suffix = if aur_has_reinstall {
"--noconfirm"
} else {
"--needed --noconfirm"
};
if dry_run {
if !aur.is_empty() && !official.is_empty() {
let tool = crate::logic::privilege::active_tool()?;
let off_cmd = crate::logic::privilege::build_privilege_command(
tool,
&format!("pacman -S {pacman_dry_flags} {}", official_quoted.join(" ")),
);
let cmd = format!(
"{off_cmd} && (paru -S --aur {aur_cli_suffix} {n} || yay -S --aur {aur_cli_suffix} {n}){hold}",
n = aur_quoted.join(" "),
hold = hold_tail
);
let quoted = shell_single_quote(&cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else if !aur.is_empty() {
let cmd = format!(
"(paru -S --aur {aur_cli_suffix} {n} || yay -S --aur {aur_cli_suffix} {n}){hold}",
n = aur_quoted.join(" "),
hold = hold_tail
);
let quoted = shell_single_quote(&cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else if !official.is_empty() {
let tool = crate::logic::privilege::active_tool()?;
let cmd = format!(
"{}{hold}",
crate::logic::privilege::build_privilege_command(
tool,
&format!("pacman -S {pacman_dry_flags} {}", official_quoted.join(" "))
),
hold = hold_tail
);
let quoted = shell_single_quote(&cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else {
Ok(format!("echo DRY RUN: nothing to install{hold_tail}"))
}
} else if !aur.is_empty() && !official.is_empty() {
let has_versions = items
.iter()
.any(|item| matches!(item.source, Source::Official { .. }) && !item.version.is_empty());
let reinstall_any = items.iter().any(|item| {
matches!(item.source, Source::Official { .. }) && crate::index::is_installed(&item.name)
});
let tool = crate::logic::privilege::active_tool()?;
let aur_body = aur_install_body(aur_s_flags, &aur_quoted.join(" "));
if has_versions && reinstall_any {
Ok(format!(
"{} bash -c 'pacman -Sy --noconfirm && pacman -S --noconfirm {n}' && {aur_body}{hold}",
tool.binary_name(),
n = official_quoted.join(" "),
aur_body = aur_body,
hold = hold_tail
))
} else {
Ok(format!(
"{} && {aur_body}{hold}",
crate::logic::privilege::build_privilege_command(
tool,
&format!(
"pacman -S --needed --noconfirm {}",
official_quoted.join(" ")
)
),
aur_body = aur_body,
hold = hold_tail
))
}
} else if !aur.is_empty() {
Ok(format!(
"{body}{hold}",
body = aur_install_body(aur_s_flags, &aur_quoted.join(" ")),
hold = hold_tail
))
} else if !official.is_empty() {
let has_versions = items
.iter()
.any(|item| matches!(item.source, Source::Official { .. }) && !item.version.is_empty());
let reinstall_any = items.iter().any(|item| {
matches!(item.source, Source::Official { .. }) && crate::index::is_installed(&item.name)
});
let tool = crate::logic::privilege::active_tool()?;
if has_versions && reinstall_any {
Ok(format!(
"{} bash -c 'pacman -Sy --noconfirm && pacman -S --noconfirm {n}'{hold}",
tool.binary_name(),
n = official_quoted.join(" "),
hold = hold_tail
))
} else {
Ok(format!(
"{}{hold}",
crate::logic::privilege::build_privilege_command(
tool,
&format!(
"pacman -S --needed --noconfirm {}",
official_quoted.join(" ")
)
),
hold = hold_tail
))
}
} else {
Ok(format!("echo nothing to install{hold_tail}"))
}
}
#[cfg(not(target_os = "windows"))]
fn try_spawn_terminal(
term: &str,
args: &[&str],
needs_xfce_command: bool,
cmd_str: &str,
) -> Result<(), ()> {
let mut cmd = Command::new(term);
if needs_xfce_command && term == "xfce4-terminal" {
let quoted = shell_single_quote(cmd_str);
cmd.arg("--command").arg(format!("bash -lc {quoted}"));
} else {
cmd.args(args.iter().copied()).arg(cmd_str);
}
if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
if let Some(parent) = std::path::Path::new(&p).parent() {
let _ = std::fs::create_dir_all(parent);
}
cmd.env("PACSEA_TEST_OUT", p);
}
if term == "konsole" && std::env::var_os("WAYLAND_DISPLAY").is_some() {
cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
}
if term == "gnome-console" || term == "kgx" {
cmd.env("GSK_RENDERER", "cairo");
cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
}
cmd.spawn().map(|_| ()).map_err(|_| ())
}
#[cfg(not(target_os = "windows"))]
pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
#[cfg(test)]
if std::env::var("PACSEA_TEST_OUT").is_err() {
return;
}
let mut official: Vec<String> = Vec::new();
let mut aur: Vec<String> = Vec::new();
for it in items {
match it.source {
Source::Official { .. } => official.push(it.name.clone()),
Source::Aur => aur.push(it.name.clone()),
}
}
let names_vec: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
tracing::info!(
total = items.len(),
aur_count = aur.len(),
official_count = official.len(),
dry_run = dry_run,
names = %names_vec.join(" "),
"spawning install"
);
let cmd_str = match build_batch_install_command(items, &official, &aur, dry_run) {
Ok(s) => s,
Err(err) => {
tracing::error!(error = %err, "privilege tool resolution failed for batch install");
return;
}
};
let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
.ok()
.is_some_and(|v| v.to_uppercase().contains("GNOME"));
let terms_gnome_first: &[(&str, &[&str], bool)] = &[
("gnome-terminal", &["--", "bash", "-lc"], false),
("gnome-console", &["--", "bash", "-lc"], false),
("kgx", &["--", "bash", "-lc"], false),
("alacritty", &["-e", "bash", "-lc"], false),
("kitty", &["bash", "-lc"], false),
("konsole", &["-e", "bash", "-lc"], false),
("xterm", &["-hold", "-e", "bash", "-lc"], false),
("xfce4-terminal", &[], true),
("tilix", &["--", "bash", "-lc"], false),
("mate-terminal", &["--", "bash", "-lc"], false),
];
let terms_default: &[(&str, &[&str], bool)] = &[
("alacritty", &["-e", "bash", "-lc"], false),
("kitty", &["bash", "-lc"], false),
("konsole", &["-e", "bash", "-lc"], false),
("gnome-terminal", &["--", "bash", "-lc"], false),
("gnome-console", &["--", "bash", "-lc"], false),
("kgx", &["--", "bash", "-lc"], false),
("xterm", &["-hold", "-e", "bash", "-lc"], false),
("xfce4-terminal", &[], true),
("tilix", &["--", "bash", "-lc"], false),
("mate-terminal", &["--", "bash", "-lc"], false),
];
let terms = if is_gnome {
terms_gnome_first
} else {
terms_default
};
let mut launched = false;
if let Some(idx) = choose_terminal_index_prefer_path(terms) {
let (term, args, needs_xfce_command) = terms[idx];
match try_spawn_terminal(term, args, needs_xfce_command, &cmd_str) {
Ok(()) => {
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");
launched = true;
}
Err(()) => {
tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
}
}
}
if !launched {
for (term, args, needs_xfce_command) in terms {
if command_on_path(term) {
match try_spawn_terminal(term, args, *needs_xfce_command, &cmd_str) {
Ok(()) => {
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");
launched = true;
break;
}
Err(()) => {
tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
}
}
}
}
}
if !launched {
let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
if let Err(e) = res {
tracing::error!(error = %e, names = %names_vec.join(" "), "failed to spawn bash to run install command");
} else {
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");
}
}
if !dry_run {
let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
if !names.is_empty()
&& let Err(e) = log_installed(&names)
{
tracing::warn!(error = %e, count = names.len(), "failed to write install audit log");
}
}
}
#[cfg(all(test, not(target_os = "windows")))]
mod tests {
#[test]
fn install_batch_uses_gnome_terminal_double_dash() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_inst_batch_gnome_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let _ = fs::create_dir_all(&dir);
let mut out_path = dir.clone();
out_path.push("args.txt");
let mut term_path = dir.clone();
term_path.push("gnome-terminal");
let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
let mut perms = fs::metadata(&term_path)
.expect("Failed to read test terminal script metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&term_path, perms)
.expect("Failed to set test terminal script permissions");
let orig_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", dir.display().to_string());
std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
}
let items = vec![
crate::state::PackageItem {
name: "rg".into(),
version: "1".into(),
description: String::new(),
source: crate::state::Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
popularity: None,
out_of_date: None,
orphaned: false,
},
crate::state::PackageItem {
name: "fd".into(),
version: "1".into(),
description: String::new(),
source: crate::state::Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
popularity: None,
out_of_date: None,
orphaned: false,
},
];
super::spawn_install_all(&items, true);
std::thread::sleep(std::time::Duration::from_millis(50));
let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
let lines: Vec<&str> = body.lines().collect();
assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
assert_eq!(lines[0], "--");
assert_eq!(lines[1], "bash");
assert_eq!(lines[2], "-lc");
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
std::env::remove_var("PACSEA_TEST_OUT");
}
}
}
#[cfg(target_os = "windows")]
#[allow(unused_variables, clippy::missing_const_for_fn)]
pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
#[cfg(not(test))]
{
let mut names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
if names.is_empty() {
names.push("nothing".into());
}
let names_str = names.join(" ");
if dry_run && super::utils::is_powershell_available() {
let powershell_cmd = format!(
"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')",
names.len(),
names_str.replace('\'', "''")
);
let _ = Command::new("powershell.exe")
.args(["-NoProfile", "-Command", &powershell_cmd])
.spawn();
} else {
let msg = if dry_run {
format!("DRY RUN: install {names_str}")
} else {
format!("Install {names_str} (not supported on Windows)")
};
let _ = Command::new("cmd")
.args([
"/C",
"start",
"Pacsea Install",
"cmd",
"/K",
&super::utils::cmd_echo_command(&msg),
])
.spawn();
}
if !dry_run {
let _ = super::logging::log_installed(&names);
}
}
}