feat: [US-001] - Add browser_install.rs CLI command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 04:37:17 -07:00
parent f25a92aca8
commit b6ba8e29ef
3 changed files with 160 additions and 6 deletions

View file

@ -0,0 +1,153 @@
use crate::desktop_install::{
detect_package_manager, find_binary, prompt_yes_no, render_install_command,
run_install_commands, running_as_root, DesktopPackageManager,
};
const AUTOMATIC_INSTALL_SUPPORTED_DISTROS: &str =
"Automatic browser dependency installation is supported on Debian/Ubuntu (apt), Fedora/RHEL (dnf), and Alpine (apk).";
const AUTOMATIC_INSTALL_UNSUPPORTED_ENVS: &str =
"Automatic installation is not supported on macOS, Windows, or Linux distributions without apt, dnf, or apk.";
#[derive(Debug, Clone)]
pub struct BrowserInstallRequest {
pub yes: bool,
pub print_only: bool,
pub package_manager: Option<DesktopPackageManager>,
}
pub(crate) fn browser_platform_support_message() -> String {
format!("Browser APIs are only supported on Linux. {AUTOMATIC_INSTALL_SUPPORTED_DISTROS}")
}
fn linux_install_support_message() -> String {
format!("{AUTOMATIC_INSTALL_SUPPORTED_DISTROS} {AUTOMATIC_INSTALL_UNSUPPORTED_ENVS}")
}
pub fn install_browser(request: BrowserInstallRequest) -> Result<(), String> {
if std::env::consts::OS != "linux" {
return Err(format!(
"browser installation is only supported on Linux. {}",
linux_install_support_message()
));
}
let package_manager = match request.package_manager {
Some(value) => value,
None => detect_package_manager().ok_or_else(|| {
format!(
"could not detect a supported package manager. {} Install the browser dependencies manually on this distribution.",
linux_install_support_message()
)
})?,
};
let packages = browser_packages(package_manager);
let used_sudo = !running_as_root() && find_binary("sudo").is_some();
if !running_as_root() && !used_sudo {
return Err(
"browser installation requires root or sudo access; rerun as root or install dependencies manually"
.to_string(),
);
}
println!("Browser package manager: {}", package_manager);
println!("Browser packages:");
for package in &packages {
println!(" - {package}");
}
println!("Install command:");
println!(
" {}",
render_install_command(package_manager, used_sudo, &packages)
);
if request.print_only {
return Ok(());
}
if !request.yes && !prompt_yes_no("Proceed with browser dependency installation? [y/N] ")? {
return Err("installation cancelled".to_string());
}
run_install_commands(package_manager, used_sudo, &packages)?;
println!("Browser dependencies installed.");
Ok(())
}
fn browser_packages(package_manager: DesktopPackageManager) -> Vec<String> {
match package_manager {
DesktopPackageManager::Apt => vec![
"chromium",
"chromium-sandbox",
"libnss3",
"libatk-bridge2.0-0",
"libdrm2",
"libxcomposite1",
"libxdamage1",
"libxrandr2",
"libgbm1",
"libasound2",
"libpangocairo-1.0-0",
"libgtk-3-0",
],
DesktopPackageManager::Dnf => vec!["chromium"],
DesktopPackageManager::Apk => vec!["chromium", "nss"],
}
.into_iter()
.map(str::to_string)
.collect()
}
/// Checks for missing browser dependencies (Chromium binary and desktop libs).
pub(crate) fn detect_missing_browser_dependencies() -> Vec<String> {
let mut missing = Vec::new();
// Check for chromium binary (may be named chromium or chromium-browser)
if find_binary("chromium").is_none() && find_binary("chromium-browser").is_none() {
missing.push("chromium".to_string());
}
// Check for key desktop dependency libraries
for (name, binary) in [("Xvfb", "Xvfb"), ("xrandr", "xrandr")] {
if find_binary(binary).is_none() {
missing.push(name.to_string());
}
}
missing
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_platform_support_message_mentions_linux_and_supported_distros() {
let message = browser_platform_support_message();
assert!(message.contains("only supported on Linux"));
assert!(message.contains("Debian/Ubuntu (apt)"));
assert!(message.contains("Fedora/RHEL (dnf)"));
assert!(message.contains("Alpine (apk)"));
}
#[test]
fn browser_packages_apt_includes_chromium_and_libs() {
let packages = browser_packages(DesktopPackageManager::Apt);
assert!(packages.iter().any(|p| p == "chromium"));
assert!(packages.iter().any(|p| p == "libnss3"));
assert!(packages.iter().any(|p| p == "libgbm1"));
}
#[test]
fn browser_packages_dnf_includes_chromium() {
let packages = browser_packages(DesktopPackageManager::Dnf);
assert_eq!(packages, vec!["chromium"]);
}
#[test]
fn browser_packages_apk_includes_chromium_and_nss() {
let packages = browser_packages(DesktopPackageManager::Apk);
assert_eq!(packages, vec!["chromium", "nss"]);
}
}

View file

@ -85,7 +85,7 @@ pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> {
Ok(())
}
fn detect_package_manager() -> Option<DesktopPackageManager> {
pub(crate) fn detect_package_manager() -> Option<DesktopPackageManager> {
if find_binary("apt-get").is_some() {
return Some(DesktopPackageManager::Apt);
}
@ -149,7 +149,7 @@ fn desktop_packages(package_manager: DesktopPackageManager, no_fonts: bool) -> V
packages
}
fn render_install_command(
pub(crate) fn render_install_command(
package_manager: DesktopPackageManager,
used_sudo: bool,
packages: &[String],
@ -169,7 +169,7 @@ fn render_install_command(
}
}
fn run_install_commands(
pub(crate) fn run_install_commands(
package_manager: DesktopPackageManager,
used_sudo: bool,
packages: &[String],
@ -233,7 +233,7 @@ fn run_command((program, args): (String, Vec<String>)) -> Result<(), String> {
Ok(())
}
fn prompt_yes_no(prompt: &str) -> Result<bool, String> {
pub(crate) fn prompt_yes_no(prompt: &str) -> Result<bool, String> {
print!("{prompt}");
io::stdout()
.flush()
@ -246,7 +246,7 @@ fn prompt_yes_no(prompt: &str) -> Result<bool, String> {
Ok(matches!(normalized.as_str(), "y" | "yes"))
}
fn running_as_root() -> bool {
pub(crate) fn running_as_root() -> bool {
#[cfg(unix)]
unsafe {
return libc::geteuid() == 0;
@ -257,7 +257,7 @@ fn running_as_root() -> bool {
}
}
fn find_binary(name: &str) -> Option<PathBuf> {
pub(crate) fn find_binary(name: &str) -> Option<PathBuf> {
let path_env = std::env::var_os("PATH")?;
for path in std::env::split_paths(&path_env) {
let candidate = path.join(name);

View file

@ -1,6 +1,7 @@
//! Sandbox agent core utilities.
mod acp_proxy_runtime;
mod browser_install;
pub mod cli;
pub mod daemon;
mod desktop_errors;