use std::path::{Path, PathBuf};
use futures::StreamExt;
use reqwest::Client;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use crate::error::{FerriError, Result};
fn backend_err(context: impl std::fmt::Display) -> FerriError {
FerriError::backend(context.to_string())
}
const CFT_VERSIONS_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
const FIREFOX_VERSIONS_URL: &str = "https://product-details.mozilla.org/1.0/firefox_versions.json";
const FIREFOX_RELEASES_URL: &str = "https://archive.mozilla.org/pub/firefox/releases";
const DOWNLOAD_RETRIES: u32 = 5;
const PW_BROWSERS_JSON_URL: &str =
"https://raw.githubusercontent.com/microsoft/playwright/main/packages/playwright-core/browsers.json";
const PW_CDN_MIRRORS: &[&str] = &[
"https://cdn.playwright.dev/dbazure/download/playwright",
"https://playwright.download.prss.microsoft.com/dbazure/download/playwright",
"https://cdn.playwright.dev",
];
#[derive(Debug, Deserialize)]
struct PwBrowsersJson {
browsers: Vec<PwBrowserEntry>,
}
#[derive(Debug, Deserialize)]
struct PwBrowserEntry {
name: String,
revision: String,
}
#[derive(Debug, Deserialize)]
struct CftResponse {
channels: CftChannels,
}
#[derive(Debug, Deserialize)]
struct CftChannels {
#[serde(rename = "Stable")]
stable: CftChannel,
}
#[derive(Debug, Deserialize)]
struct CftChannel {
version: String,
downloads: CftDownloads,
}
#[derive(Debug, Deserialize)]
struct CftDownloads {
chrome: Vec<CftDownload>,
#[serde(rename = "chrome-headless-shell")]
chrome_headless_shell: Vec<CftDownload>,
}
#[derive(Debug, Deserialize)]
struct CftDownload {
platform: String,
url: String,
}
#[derive(Debug, Clone)]
pub enum InstallProgress {
Resolving,
Downloading {
bytes_downloaded: u64,
total_bytes: Option<u64>,
},
Extracting,
Complete { version: String, path: String },
AlreadyInstalled { version: String, path: String },
InstallingDeps { distro: String },
DepsInstalled,
}
pub struct BrowserInstaller {
cache_dir: PathBuf,
client: Client,
}
impl BrowserInstaller {
#[must_use]
pub fn new() -> Self {
let cache_dir = if let Ok(p) = std::env::var("FERRIDRIVER_BROWSERS_PATH") {
PathBuf::from(p)
} else {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("ferridriver")
};
Self {
cache_dir,
client: Client::new(),
}
}
#[must_use]
pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
client: Client::new(),
}
}
#[must_use]
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub async fn install_chromium<F>(&self, progress: F) -> Result<String>
where
F: Fn(InstallProgress),
{
progress(InstallProgress::Resolving);
let cft: CftResponse = self
.client
.get(CFT_VERSIONS_URL)
.send()
.await
.map_err(|e| backend_err(format!("failed to fetch Chrome for Testing versions: {e}")))?
.json()
.await
.map_err(|e| backend_err(format!("failed to parse Chrome for Testing response: {e}")))?;
let version = &cft.channels.stable.version;
let platform = current_platform();
let download = cft
.channels
.stable
.downloads
.chrome
.iter()
.find(|d| d.platform == platform)
.ok_or_else(|| FerriError::unsupported(format!("no Chrome for Testing build for platform: {platform}")))?;
let install_dir = self.cache_dir.join(format!("chromium-{version}"));
let marker_file = install_dir.join(".downloaded");
let executable = chrome_executable_path(&install_dir, &platform);
if marker_file.exists() && executable.exists() {
let path = executable.to_string_lossy().to_string();
progress(InstallProgress::AlreadyInstalled {
version: version.clone(),
path: path.clone(),
});
return Ok(path);
}
if install_dir.exists() {
let _ = tokio::fs::remove_dir_all(&install_dir).await;
}
let tmp_dir = self.cache_dir.join(".tmp");
tokio::fs::create_dir_all(&tmp_dir).await?;
let zip_path = tmp_dir.join(format!("chrome-{version}-{platform}.zip"));
let mut last_error = String::new();
for attempt in 1..=DOWNLOAD_RETRIES {
progress(InstallProgress::Downloading {
bytes_downloaded: 0,
total_bytes: None,
});
match self.download_file(&download.url, &zip_path, &progress).await {
Ok(()) => {
last_error.clear();
break;
},
Err(e) => {
last_error = format!("attempt {attempt}/{DOWNLOAD_RETRIES}: {e}");
let _ = tokio::fs::remove_file(&zip_path).await;
if attempt == DOWNLOAD_RETRIES {
return Err(backend_err(format!(
"download failed after {DOWNLOAD_RETRIES} attempts: {last_error}"
)));
}
},
}
}
progress(InstallProgress::Extracting);
tokio::fs::create_dir_all(&install_dir).await?;
let install_dir_clone = install_dir.clone();
let zip_path_clone = zip_path.clone();
tokio::task::spawn_blocking(move || extract_zip(&zip_path_clone, &install_dir_clone))
.await
.map_err(|e| backend_err(format!("extract task failed: {e}")))??;
let _ = tokio::fs::remove_file(&zip_path).await;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if executable.exists() {
let _ = std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755));
}
}
let path = executable.to_string_lossy().to_string();
if !executable.exists() {
return Err(backend_err(format!(
"extraction completed but chrome executable not found at: {path}"
)));
}
let _ = tokio::fs::write(&marker_file, version.as_bytes()).await;
progress(InstallProgress::Complete {
version: version.clone(),
path: path.clone(),
});
Ok(path)
}
async fn download_file<F>(&self, url: &str, dest: &Path, progress: &F) -> Result<()>
where
F: Fn(InstallProgress),
{
let response = self
.client
.get(url)
.send()
.await
.map_err(|e| backend_err(format!("request failed: {e}")))?;
if !response.status().is_success() {
return Err(backend_err(format!("HTTP {}: {url}", response.status())));
}
let total_bytes = response.content_length();
let mut bytes_downloaded: u64 = 0;
let mut file = tokio::fs::File::create(dest).await?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| backend_err(format!("download error: {e}")))?;
file.write_all(&chunk).await?;
bytes_downloaded += chunk.len() as u64;
progress(InstallProgress::Downloading {
bytes_downloaded,
total_bytes,
});
}
file.flush().await?;
Ok(())
}
#[allow(clippy::unused_async)] pub async fn install_system_deps<F>(&self, progress: F) -> Result<()>
where
F: Fn(InstallProgress),
{
#[cfg(not(target_os = "linux"))]
{
let _ = progress;
Ok(())
}
#[cfg(target_os = "linux")]
{
let distro = detect_linux_distro();
let (pkg_manager, packages) = system_packages_for_distro(&distro);
if packages.is_empty() {
return Err(FerriError::unsupported(format!(
"Linux distribution {distro}: cannot determine required packages"
)));
}
progress(InstallProgress::InstallingDeps { distro: distro.clone() });
let commands = match pkg_manager {
PackageManager::Apt => format!(
"apt-get update && apt-get install -y --no-install-recommends {}",
packages.join(" ")
),
PackageManager::Pacman => format!("pacman -Sy --noconfirm --needed {}", packages.join(" ")),
};
#[allow(unsafe_code)]
let uid = unsafe { libc::getuid() };
let (cmd, args) = if uid == 0 {
("sh".to_string(), vec!["-c".to_string(), commands])
} else {
(
"sudo".to_string(),
vec!["--".to_string(), "sh".to_string(), "-c".to_string(), commands],
)
};
let status = tokio::process::Command::new(&cmd)
.args(&args)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await?;
if !status.success() {
let tool = match pkg_manager {
PackageManager::Apt => "apt-get",
PackageManager::Pacman => "pacman",
};
return Err(backend_err(format!(
"{tool} exited with code: {}",
status.code().unwrap_or(-1)
)));
}
progress(InstallProgress::DepsInstalled);
Ok(())
}
}
#[must_use]
pub fn find_installed_chromium(&self) -> Option<String> {
let entries = std::fs::read_dir(&self.cache_dir).ok()?;
let mut candidates: Vec<_> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-"))
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
let platform = current_platform();
for entry in candidates {
let marker = entry.path().join(".downloaded");
let exe = chrome_executable_path(&entry.path(), &platform);
if marker.exists() && exe.exists() {
return Some(exe.to_string_lossy().to_string());
}
}
None
}
pub async fn install_chromium_headless_shell<F>(&self, progress: F) -> Result<String>
where
F: Fn(InstallProgress),
{
progress(InstallProgress::Resolving);
let cft: CftResponse = self
.client
.get(CFT_VERSIONS_URL)
.send()
.await
.map_err(|e| backend_err(format!("failed to fetch Chrome for Testing versions: {e}")))?
.json()
.await
.map_err(|e| backend_err(format!("failed to parse Chrome for Testing response: {e}")))?;
let version = &cft.channels.stable.version;
let platform = current_platform();
let download = cft
.channels
.stable
.downloads
.chrome_headless_shell
.iter()
.find(|d| d.platform == platform)
.ok_or_else(|| FerriError::unsupported(format!("no Chrome Headless Shell build for platform: {platform}")))?;
let install_dir = self.cache_dir.join(format!("chromium-headless-shell-{version}"));
let marker_file = install_dir.join(".downloaded");
let executable = headless_shell_executable_path(&install_dir, &platform);
if marker_file.exists() && executable.exists() {
let path = executable.to_string_lossy().to_string();
progress(InstallProgress::AlreadyInstalled {
version: version.clone(),
path: path.clone(),
});
return Ok(path);
}
if install_dir.exists() {
let _ = tokio::fs::remove_dir_all(&install_dir).await;
}
let tmp_dir = self.cache_dir.join(".tmp");
tokio::fs::create_dir_all(&tmp_dir).await?;
let zip_path = tmp_dir.join(format!("chrome-headless-shell-{version}-{platform}.zip"));
let mut last_error = String::new();
for attempt in 1..=DOWNLOAD_RETRIES {
progress(InstallProgress::Downloading {
bytes_downloaded: 0,
total_bytes: None,
});
match self.download_file(&download.url, &zip_path, &progress).await {
Ok(()) => {
last_error.clear();
break;
},
Err(e) => {
last_error = format!("attempt {attempt}/{DOWNLOAD_RETRIES}: {e}");
let _ = tokio::fs::remove_file(&zip_path).await;
if attempt == DOWNLOAD_RETRIES {
return Err(backend_err(format!(
"download failed after {DOWNLOAD_RETRIES} attempts: {last_error}"
)));
}
},
}
}
progress(InstallProgress::Extracting);
tokio::fs::create_dir_all(&install_dir).await?;
let install_dir_clone = install_dir.clone();
let zip_path_clone = zip_path.clone();
tokio::task::spawn_blocking(move || extract_zip(&zip_path_clone, &install_dir_clone))
.await
.map_err(|e| backend_err(format!("extract task failed: {e}")))??;
let _ = tokio::fs::remove_file(&zip_path).await;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if executable.exists() {
let _ = std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755));
}
}
let path = executable.to_string_lossy().to_string();
if !executable.exists() {
return Err(backend_err(format!(
"extraction completed but chrome-headless-shell executable not found at: {path}"
)));
}
let _ = tokio::fs::write(&marker_file, version.as_bytes()).await;
progress(InstallProgress::Complete {
version: version.clone(),
path: path.clone(),
});
Ok(path)
}
#[must_use]
pub fn find_installed_headless_shell(&self) -> Option<String> {
let entries = std::fs::read_dir(&self.cache_dir).ok()?;
let mut candidates: Vec<_> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-headless-shell-"))
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
let platform = current_platform();
for entry in candidates {
let marker = entry.path().join(".downloaded");
let exe = headless_shell_executable_path(&entry.path(), &platform);
if marker.exists() && exe.exists() {
return Some(exe.to_string_lossy().to_string());
}
}
None
}
pub async fn install_firefox<F>(&self, progress: F) -> Result<String>
where
F: Fn(InstallProgress),
{
progress(InstallProgress::Resolving);
let versions: std::collections::HashMap<String, String> = self
.client
.get(FIREFOX_VERSIONS_URL)
.send()
.await
.map_err(|e| backend_err(format!("failed to fetch Firefox versions: {e}")))?
.json()
.await
.map_err(|e| backend_err(format!("failed to parse Firefox versions: {e}")))?;
let version = versions
.get("LATEST_FIREFOX_VERSION")
.ok_or_else(|| backend_err("Firefox versions response missing LATEST_FIREFOX_VERSION"))?
.clone();
let (platform_dir, archive_name, archive_ext) = firefox_archive_info(&version)?;
let download_url = format!("{FIREFOX_RELEASES_URL}/{version}/{platform_dir}/en-US/{archive_name}");
let install_dir = self.cache_dir.join(format!("firefox-{version}"));
let marker_file = install_dir.join(".downloaded");
let executable = firefox_executable_path(&install_dir);
if marker_file.exists() && executable.exists() {
let path = executable.to_string_lossy().to_string();
progress(InstallProgress::AlreadyInstalled {
version: version.clone(),
path: path.clone(),
});
return Ok(path);
}
if install_dir.exists() {
let _ = tokio::fs::remove_dir_all(&install_dir).await;
}
let tmp_dir = self.cache_dir.join(".tmp");
tokio::fs::create_dir_all(&tmp_dir).await?;
let archive_path = tmp_dir.join(format!("firefox-{version}{archive_ext}"));
let mut last_error = String::new();
for attempt in 1..=DOWNLOAD_RETRIES {
progress(InstallProgress::Downloading {
bytes_downloaded: 0,
total_bytes: None,
});
match self.download_file(&download_url, &archive_path, &progress).await {
Ok(()) => {
last_error.clear();
break;
},
Err(e) => {
last_error = format!("attempt {attempt}/{DOWNLOAD_RETRIES}: {e}");
let _ = tokio::fs::remove_file(&archive_path).await;
if attempt == DOWNLOAD_RETRIES {
return Err(backend_err(format!(
"Firefox download failed after {DOWNLOAD_RETRIES} attempts: {last_error}"
)));
}
},
}
}
progress(InstallProgress::Extracting);
tokio::fs::create_dir_all(&install_dir).await?;
let install_dir_clone = install_dir.clone();
let archive_path_clone = archive_path.clone();
tokio::task::spawn_blocking(move || extract_firefox_archive(&archive_path_clone, &install_dir_clone))
.await
.map_err(|e| backend_err(format!("extract task failed: {e}")))??;
let _ = tokio::fs::remove_file(&archive_path).await;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if executable.exists() {
let _ = std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755));
}
}
let path = executable.to_string_lossy().to_string();
if !executable.exists() {
return Err(backend_err(format!(
"extraction completed but firefox executable not found at: {path}"
)));
}
let _ = tokio::fs::write(&marker_file, version.as_bytes()).await;
progress(InstallProgress::Complete {
version: version.clone(),
path: path.clone(),
});
Ok(path)
}
pub async fn install_webkit<F>(&self, progress: F) -> Result<String>
where
F: Fn(InstallProgress),
{
progress(InstallProgress::Resolving);
let browsers: PwBrowsersJson = self
.client
.get(PW_BROWSERS_JSON_URL)
.send()
.await
.map_err(|e| backend_err(format!("failed to fetch Playwright browsers.json: {e}")))?
.json()
.await
.map_err(|e| backend_err(format!("failed to parse Playwright browsers.json: {e}")))?;
let revision = browsers
.browsers
.into_iter()
.find(|b| b.name == "webkit")
.map(|b| b.revision)
.ok_or_else(|| backend_err("Playwright browsers.json missing webkit entry"))?;
let archive = webkit_archive_for_host()?;
let webkit_root = self.cache_dir.join("webkit");
let install_dir = webkit_root.join(format!("webkit-{revision}"));
let marker_file = install_dir.join(".downloaded");
let executable = install_dir.join("pw_run.sh");
if marker_file.exists() && executable.exists() {
let path = executable.to_string_lossy().to_string();
progress(InstallProgress::AlreadyInstalled {
version: revision.clone(),
path: path.clone(),
});
return Ok(path);
}
if install_dir.exists() {
let _ = tokio::fs::remove_dir_all(&install_dir).await;
}
let tmp_dir = self.cache_dir.join(".tmp");
tokio::fs::create_dir_all(&tmp_dir).await?;
let zip_path = tmp_dir.join(format!("webkit-{revision}.zip"));
let mut last_error = String::new();
'outer: for host in PW_CDN_MIRRORS {
let url = format!("{host}/builds/webkit/{revision}/{archive}");
for attempt in 1..=DOWNLOAD_RETRIES {
progress(InstallProgress::Downloading {
bytes_downloaded: 0,
total_bytes: None,
});
match self.download_file(&url, &zip_path, &progress).await {
Ok(()) => {
last_error.clear();
break 'outer;
},
Err(e) => {
last_error = format!("{host} attempt {attempt}/{DOWNLOAD_RETRIES}: {e}");
let _ = tokio::fs::remove_file(&zip_path).await;
},
}
}
}
if !last_error.is_empty() {
return Err(backend_err(format!(
"WebKit download failed after exhausting mirrors: {last_error}"
)));
}
progress(InstallProgress::Extracting);
tokio::fs::create_dir_all(&install_dir).await?;
let install_dir_clone = install_dir.clone();
let zip_path_clone = zip_path.clone();
tokio::task::spawn_blocking(move || extract_zip(&zip_path_clone, &install_dir_clone))
.await
.map_err(|e| backend_err(format!("extract task failed: {e}")))??;
let _ = tokio::fs::remove_file(&zip_path).await;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if executable.exists() {
let _ = std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755));
}
}
if !executable.exists() {
return Err(backend_err(format!(
"extraction completed but pw_run.sh not found at: {}",
executable.display()
)));
}
let _ = tokio::fs::write(&marker_file, revision.as_bytes()).await;
let path = executable.to_string_lossy().to_string();
progress(InstallProgress::Complete {
version: revision,
path: path.clone(),
});
Ok(path)
}
#[must_use]
pub fn find_installed_firefox(&self) -> Option<String> {
let entries = std::fs::read_dir(&self.cache_dir).ok()?;
let mut candidates: Vec<_> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("firefox-"))
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for entry in candidates {
let marker = entry.path().join(".downloaded");
let exe = firefox_executable_path(&entry.path());
if marker.exists() && exe.exists() {
return Some(exe.to_string_lossy().to_string());
}
}
None
}
}
impl Default for BrowserInstaller {
fn default() -> Self {
Self::new()
}
}
fn webkit_archive_for_host() -> Result<String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let archive = match (os, arch) {
#[cfg(target_os = "linux")]
("linux", _) => {
let distro = detect_linux_distro();
if distro.starts_with("ubuntu20.04") {
if arch == "aarch64" {
"webkit-ubuntu-20.04-arm64.zip"
} else {
"webkit-ubuntu-20.04.zip"
}
} else if distro.starts_with("ubuntu22.04") || distro.starts_with("debian11") {
if arch == "aarch64" {
"webkit-ubuntu-22.04-arm64.zip"
} else {
"webkit-ubuntu-22.04.zip"
}
} else if distro.starts_with("debian12") {
"webkit-debian-12.zip"
} else if arch == "aarch64" {
"webkit-ubuntu-24.04-arm64.zip"
} else {
"webkit-ubuntu-24.04.zip"
}
},
#[cfg(not(target_os = "linux"))]
("linux", "aarch64") => "webkit-ubuntu-24.04-arm64.zip",
#[cfg(not(target_os = "linux"))]
("linux", _) => "webkit-ubuntu-24.04.zip",
("macos", "aarch64") => "webkit-mac-15-arm64.zip",
("macos", _) => "webkit-mac-15.zip",
("windows", _) => "webkit-win64.zip",
_ => {
return Err(FerriError::unsupported(format!(
"no Playwright WebKit build for {os}/{arch}"
)));
},
};
Ok(archive.to_string())
}
fn current_platform() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64" | "aarch64") => "linux64".to_string(),
("macos", "x86_64") => "mac-x64".to_string(),
("macos", "aarch64") => "mac-arm64".to_string(),
("windows", "x86_64") => "win64".to_string(),
("windows", "x86") => "win32".to_string(),
_ => format!("{os}-{arch}"),
}
}
fn chrome_executable_path(install_dir: &Path, platform: &str) -> PathBuf {
match platform {
"linux64" => install_dir.join("chrome-linux64/chrome"),
"mac-x64" => {
install_dir.join("chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")
},
"mac-arm64" => {
install_dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")
},
"win64" => install_dir.join("chrome-win64/chrome.exe"),
"win32" => install_dir.join("chrome-win32/chrome.exe"),
_ => install_dir.join("chrome"),
}
}
fn headless_shell_executable_path(install_dir: &Path, platform: &str) -> PathBuf {
match platform {
"linux64" => install_dir.join("chrome-headless-shell-linux64/chrome-headless-shell"),
"mac-x64" => install_dir.join("chrome-headless-shell-mac-x64/chrome-headless-shell"),
"mac-arm64" => install_dir.join("chrome-headless-shell-mac-arm64/chrome-headless-shell"),
"win64" => install_dir.join("chrome-headless-shell-win64/chrome-headless-shell.exe"),
"win32" => install_dir.join("chrome-headless-shell-win32/chrome-headless-shell.exe"),
_ => install_dir.join("chrome-headless-shell"),
}
}
#[cfg(target_os = "linux")]
fn detect_linux_distro() -> String {
let arch_suffix = match std::env::consts::ARCH {
"aarch64" => "-arm64",
_ => "-x64",
};
let (id, version) = read_os_release().unwrap_or_default();
match id.as_str() {
"ubuntu" | "pop" | "neon" | "tuxedo" => {
let major: u32 = version.split('.').next().and_then(|s| s.parse().ok()).unwrap_or(24);
if major < 22 {
format!("ubuntu20.04{arch_suffix}")
} else if major < 24 {
format!("ubuntu22.04{arch_suffix}")
} else {
format!("ubuntu24.04{arch_suffix}")
}
},
"linuxmint" => {
let major: u32 = version.split('.').next().and_then(|s| s.parse().ok()).unwrap_or(22);
if major <= 20 {
format!("ubuntu20.04{arch_suffix}")
} else if major == 21 {
format!("ubuntu22.04{arch_suffix}")
} else {
format!("ubuntu24.04{arch_suffix}")
}
},
"debian" | "raspbian" => match version.as_str() {
"11" => format!("debian11{arch_suffix}"),
"12" => format!("debian12{arch_suffix}"),
_ => format!("debian13{arch_suffix}"),
},
"arch" | "manjaro" | "endeavouros" | "garuda" | "artix" | "cachyos" => {
format!("arch{arch_suffix}")
},
_ => format!("ubuntu24.04{arch_suffix}"),
}
}
#[cfg(target_os = "linux")]
fn read_os_release() -> Option<(String, String)> {
let content = std::fs::read_to_string("/etc/os-release").ok()?;
let mut id = String::new();
let mut version = String::new();
for line in content.lines() {
if let Some(val) = line.strip_prefix("ID=") {
id = val.trim_matches('"').to_lowercase();
} else if let Some(val) = line.strip_prefix("VERSION_ID=") {
version = val.trim_matches('"').to_string();
}
}
Some((id, version))
}
#[cfg(target_os = "linux")]
enum PackageManager {
Apt,
Pacman,
}
#[cfg(target_os = "linux")]
fn system_packages_for_distro(distro: &str) -> (PackageManager, Vec<&'static str>) {
if distro.starts_with("arch") {
let mut pkgs = arch_chromium_packages();
pkgs.extend(arch_webkit_packages());
pkgs.sort_unstable();
pkgs.dedup();
return (PackageManager::Pacman, pkgs);
}
let mut merged: Vec<&'static str> = apt_chromium_packages(distro)
.iter()
.copied()
.chain(apt_webkit_packages(distro).iter().copied())
.collect();
merged.sort_unstable();
merged.dedup();
(PackageManager::Apt, merged)
}
#[cfg(target_os = "linux")]
fn apt_chromium_packages(distro: &str) -> &'static [&'static str] {
match distro {
d if d.starts_with("ubuntu20.04") => &[
"libasound2",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libatspi2.0-0",
"libcairo2",
"libcups2",
"libdbus-1-3",
"libdrm2",
"libgbm1",
"libglib2.0-0",
"libnspr4",
"libnss3",
"libpango-1.0-0",
"libxcb1",
"libxcomposite1",
"libxdamage1",
"libxfixes3",
"libxrandr2",
"libxkbcommon0",
"fonts-liberation",
"libx11-6",
"libxext6",
"libwayland-client0",
"fonts-noto-color-emoji",
],
d if d.starts_with("ubuntu22.04") | d.starts_with("debian11") | d.starts_with("debian12") => &[
"libasound2",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libatspi2.0-0",
"libcairo2",
"libcups2",
"libdbus-1-3",
"libdrm2",
"libgbm1",
"libglib2.0-0",
"libnspr4",
"libnss3",
"libpango-1.0-0",
"libxcb1",
"libxcomposite1",
"libxdamage1",
"libxfixes3",
"libxrandr2",
"libxkbcommon0",
"fonts-liberation",
"libx11-6",
"libxext6",
"libwayland-client0",
"fonts-noto-color-emoji",
],
_ => &[
"libasound2t64",
"libatk-bridge2.0-0t64",
"libatk1.0-0t64",
"libatspi2.0-0t64",
"libcairo2",
"libcups2t64",
"libdbus-1-3",
"libdrm2",
"libgbm1",
"libglib2.0-0t64",
"libnspr4",
"libnss3",
"libpango-1.0-0",
"libxcb1",
"libxcomposite1",
"libxdamage1",
"libxfixes3",
"libxrandr2",
"libxkbcommon0",
"fonts-liberation",
"libx11-6",
"libxext6",
"libwayland-client0",
"fonts-noto-color-emoji",
],
}
}
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_lines)] fn apt_webkit_packages(distro: &str) -> &'static [&'static str] {
match distro {
d if d.starts_with("ubuntu20.04") => &[
"libenchant-2-2",
"libflite1",
"libx264-155",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libcairo2",
"libegl1",
"libenchant1c2a",
"libepoxy0",
"libevdev2",
"libfontconfig1",
"libfreetype6",
"libgdk-pixbuf2.0-0",
"libgl1",
"libgles2",
"libglib2.0-0",
"libgtk-3-0",
"libgudev-1.0-0",
"libharfbuzz-icu0",
"libharfbuzz0b",
"libhyphen0",
"libicu66",
"libjpeg-turbo8",
"libnghttp2-14",
"libnotify4",
"libopengl0",
"libopenjp2-7",
"libopus0",
"libpango-1.0-0",
"libpng16-16",
"libsecret-1-0",
"libvpx6",
"libwayland-client0",
"libwayland-egl1",
"libwayland-server0",
"libwebp6",
"libwebpdemux2",
"libwoff1",
"libx11-6",
"libxcomposite1",
"libxdamage1",
"libxkbcommon0",
"libxml2",
"libxslt1.1",
"libatomic1",
"libevent-2.1-7",
],
d if d.starts_with("ubuntu22.04") | d.starts_with("debian11") | d.starts_with("debian12") => &[
"libsoup-3.0-0",
"libenchant-2-2",
"gstreamer1.0-libav",
"gstreamer1.0-plugins-bad",
"gstreamer1.0-plugins-base",
"gstreamer1.0-plugins-good",
"libicu70",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libcairo2",
"libdbus-1-3",
"libdrm2",
"libegl1",
"libepoxy0",
"libevdev2",
"libffi7",
"libfontconfig1",
"libfreetype6",
"libgbm1",
"libgdk-pixbuf-2.0-0",
"libgles2",
"libglib2.0-0",
"libglx0",
"libgstreamer-gl1.0-0",
"libgstreamer-plugins-base1.0-0",
"libgstreamer1.0-0",
"libgtk-4-1",
"libgudev-1.0-0",
"libharfbuzz-icu0",
"libharfbuzz0b",
"libhyphen0",
"libjpeg-turbo8",
"liblcms2-2",
"libmanette-0.2-0",
"libnotify4",
"libopengl0",
"libopenjp2-7",
"libopus0",
"libpango-1.0-0",
"libpng16-16",
"libproxy1v5",
"libsecret-1-0",
"libwayland-client0",
"libwayland-egl1",
"libwayland-server0",
"libwebpdemux2",
"libwoff1",
"libx11-6",
"libxcomposite1",
"libxdamage1",
"libxkbcommon0",
"libxml2",
"libxslt1.1",
"libx264-163",
"libatomic1",
"libevent-2.1-7",
"libavif13",
],
_ => &[
"gstreamer1.0-libav",
"gstreamer1.0-plugins-bad",
"gstreamer1.0-plugins-base",
"gstreamer1.0-plugins-good",
"libicu74",
"libatomic1",
"libatk-bridge2.0-0t64",
"libatk1.0-0t64",
"libcairo-gobject2",
"libcairo2",
"libdbus-1-3",
"libdrm2",
"libenchant-2-2",
"libepoxy0",
"libevent-2.1-7t64",
"libflite1",
"libfontconfig1",
"libfreetype6",
"libgbm1",
"libgdk-pixbuf-2.0-0",
"libgles2",
"libglib2.0-0t64",
"libgstreamer-gl1.0-0",
"libgstreamer-plugins-bad1.0-0",
"libgstreamer-plugins-base1.0-0",
"libgstreamer1.0-0",
"libgtk-4-1",
"libharfbuzz-icu0",
"libharfbuzz0b",
"libhyphen0",
"libjpeg-turbo8",
"liblcms2-2",
"libmanette-0.2-0",
"libopus0",
"libpango-1.0-0",
"libpangocairo-1.0-0",
"libpng16-16t64",
"libsecret-1-0",
"libvpx9",
"libwayland-client0",
"libwayland-egl1",
"libwayland-server0",
"libwebp7",
"libwebpdemux2",
"libwoff1",
"libx11-6",
"libxkbcommon0",
"libxml2",
"libxslt1.1",
"libx264-164",
"libavif16",
],
}
}
#[cfg(target_os = "linux")]
fn arch_chromium_packages() -> Vec<&'static str> {
vec![
"alsa-lib", "at-spi2-core", "cairo", "libcups", "dbus", "libdrm", "mesa", "glib2", "nspr", "nss", "pango", "libxcb", "libxcomposite", "libxdamage", "libxfixes", "libxrandr", "libxkbcommon", "libx11", "libxext", "wayland", "ttf-liberation", "noto-fonts-emoji", "fontconfig", "freetype2", ]
}
#[cfg(target_os = "linux")]
fn arch_webkit_packages() -> Vec<&'static str> {
vec![
"woff2", "libvpx", "opus", "libwebp", "harfbuzz-icu", "enchant", "hyphen", "libsecret", "libnotify", "mesa", "libxslt", "libevent", "libgudev", "libsoup3", "libavif", "gst-plugins-base",
"gst-plugins-base-libs",
"gst-plugins-good",
"gst-plugins-bad",
"gst-libav",
"gtk4",
]
}
fn firefox_archive_info(version: &str) -> Result<(String, String, String)> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let major: u32 = version.split('.').next().and_then(|s| s.parse().ok()).unwrap_or(0);
let tar_ext = if major >= 135 { "xz" } else { "bz2" };
match (os, arch) {
("linux", "x86_64") => Ok((
"linux-x86_64".into(),
format!("firefox-{version}.tar.{tar_ext}"),
format!(".tar.{tar_ext}"),
)),
("linux", "aarch64") => Ok((
"linux-aarch64".into(),
format!("firefox-{version}.tar.{tar_ext}"),
format!(".tar.{tar_ext}"),
)),
("macos", "x86_64" | "aarch64") => Ok(("mac".into(), format!("Firefox {version}.dmg"), ".dmg".into())),
("windows", "x86_64") => Ok(("win64".into(), format!("Firefox Setup {version}.exe"), ".exe".into())),
("windows", "x86") => Ok(("win32".into(), format!("Firefox Setup {version}.exe"), ".exe".into())),
_ => Err(FerriError::unsupported(format!(
"unsupported platform for Firefox: {os}-{arch}"
))),
}
}
fn firefox_executable_path(install_dir: &Path) -> PathBuf {
let os = std::env::consts::OS;
match os {
"macos" => install_dir.join("Firefox.app/Contents/MacOS/firefox"),
"windows" => install_dir.join("core/firefox.exe"),
_ => install_dir.join("firefox/firefox"),
}
}
fn extract_firefox_archive(archive_path: &Path, dest: &Path) -> Result<()> {
let path_str = archive_path.to_string_lossy();
if path_str.ends_with(".tar.bz2") {
extract_tar_bz2(archive_path, dest)
} else if path_str.ends_with(".tar.xz") {
extract_tar_xz(archive_path, dest)
} else if path_str.ends_with(".dmg") {
extract_dmg(archive_path, dest)
} else {
Err(FerriError::unsupported(format!(
"unsupported Firefox archive format: {path_str}"
)))
}
}
fn extract_tar_bz2(archive_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let decoder = bzip2::read::BzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
fn extract_tar_xz(archive_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let decoder = xz2::read::XzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
fn extract_dmg(dmg_path: &Path, dest: &Path) -> Result<()> {
let output = std::process::Command::new("hdiutil")
.args(["attach", "-nobrowse", "-noautoopen"])
.arg(dmg_path)
.output()?;
if !output.status.success() {
return Err(backend_err(format!(
"hdiutil attach failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mount_path = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
parts.last().map(|p| p.trim().to_string())
})
.find(|p| p.starts_with("/Volumes/"))
.ok_or_else(|| backend_err("could not find volume mount path in hdiutil output"))?;
let result = (|| -> Result<()> {
let entries = std::fs::read_dir(&mount_path)?;
let app_name = entries
.filter_map(std::result::Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with(".app"))
.ok_or_else(|| backend_err("no .app found in mounted DMG"))?;
let source = std::path::Path::new(&mount_path).join(app_name.file_name());
let status = std::process::Command::new("cp")
.args(["-R"])
.arg(&source)
.arg(dest)
.status()?;
if !status.success() {
return Err(backend_err("cp -R failed to copy Firefox.app"));
}
Ok(())
})();
let _ = std::process::Command::new("hdiutil")
.args(["detach", &mount_path, "-quiet"])
.status();
result
}
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| backend_err(format!("failed to read zip archive: {e}")))?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| backend_err(format!("failed to read zip entry {i}: {e}")))?;
let name = entry.name().to_string();
let out_path = dest.join(&name);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = entry.unix_mode() {
if mode & 0o170_000 == 0o120_000 {
let mut target = String::new();
entry.read_to_string(&mut target)?;
let _ = std::fs::remove_file(&out_path);
std::os::unix::fs::symlink(&target, &out_path)?;
continue;
}
let mut out_file = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out_file)?;
let _ = std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode));
continue;
}
}
let mut out_file = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out_file)?;
}
Ok(())
}