* windows debugging
* fixes
* fixes
* fix: handle Windows path separators in Chrome zip extraction
The zip crate's enclosed_name() normalizes paths to use backslashes on
Windows, but extract_zip used split_once('/') which only matches forward
slashes. This caused Chrome to be extracted into a nested chrome-win64/
subdirectory instead of directly into the version directory.
Also adds debug diagnostics to find_installed_chrome() (gated behind
AGENT_BROWSER_DEBUG) and better error messages when Chrome cache exists
but no binary is found.
Fixes #1076
* feat: add Puppeteer browser cache as Chrome fallback
Search ~/.cache/puppeteer/chrome/ (or PUPPETEER_CACHE_DIR) for Chrome
binaries before falling back to Playwright's cache. Puppeteer v19+
stores Chrome for Testing in this location, so users with an existing
Puppeteer install can use agent-browser without a separate install step.
* fmt
826 lines
26 KiB
Rust
826 lines
26 KiB
Rust
use crate::color;
|
|
use std::fs;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{exit, Command, Stdio};
|
|
|
|
const LAST_KNOWN_GOOD_URL: &str =
|
|
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
|
|
|
pub fn get_browsers_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".agent-browser")
|
|
.join("browsers")
|
|
}
|
|
|
|
pub fn find_installed_chrome() -> Option<PathBuf> {
|
|
let browsers_dir = get_browsers_dir();
|
|
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
|
|
|
|
if debug {
|
|
let _ = writeln!(
|
|
io::stderr(),
|
|
"[chrome-search] home_dir={:?} browsers_dir={}",
|
|
dirs::home_dir(),
|
|
browsers_dir.display()
|
|
);
|
|
}
|
|
|
|
if !browsers_dir.exists() {
|
|
if debug {
|
|
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
|
|
}
|
|
return None;
|
|
}
|
|
|
|
let entries = match fs::read_dir(&browsers_dir) {
|
|
Ok(entries) => entries,
|
|
Err(e) => {
|
|
let _ = writeln!(
|
|
io::stderr(),
|
|
"Warning: cannot read Chrome cache directory {}: {}",
|
|
browsers_dir.display(),
|
|
e
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let mut versions: Vec<_> = entries
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
let matches = e
|
|
.file_name()
|
|
.to_str()
|
|
.is_some_and(|n| n.starts_with("chrome-"));
|
|
if debug {
|
|
let _ = writeln!(
|
|
io::stderr(),
|
|
"[chrome-search] entry {:?} matches={}",
|
|
e.file_name(),
|
|
matches
|
|
);
|
|
}
|
|
matches
|
|
})
|
|
.collect();
|
|
|
|
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
|
|
|
for entry in versions {
|
|
let dir = entry.path();
|
|
if let Some(bin) = chrome_binary_in_dir(&dir) {
|
|
let exists = bin.exists();
|
|
if debug {
|
|
let _ = writeln!(
|
|
io::stderr(),
|
|
"[chrome-search] candidate {} exists={}",
|
|
bin.display(),
|
|
exists
|
|
);
|
|
}
|
|
if exists {
|
|
return Some(bin);
|
|
}
|
|
} else if debug {
|
|
let _ = writeln!(
|
|
io::stderr(),
|
|
"[chrome-search] no binary found in {}",
|
|
dir.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
if debug {
|
|
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
|
|
}
|
|
None
|
|
}
|
|
|
|
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let app =
|
|
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
|
if app.exists() {
|
|
return Some(app);
|
|
}
|
|
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
|
if inner.exists() {
|
|
return Some(inner);
|
|
}
|
|
let inner_x64 = dir.join(
|
|
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
);
|
|
if inner_x64.exists() {
|
|
return Some(inner_x64);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let bin = dir.join("chrome");
|
|
if bin.exists() {
|
|
return Some(bin);
|
|
}
|
|
let inner = dir.join("chrome-linux64/chrome");
|
|
if inner.exists() {
|
|
return Some(inner);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let bin = dir.join("chrome.exe");
|
|
if bin.exists() {
|
|
return Some(bin);
|
|
}
|
|
let inner = dir.join("chrome-win64/chrome.exe");
|
|
if inner.exists() {
|
|
return Some(inner);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
|
{
|
|
None
|
|
}
|
|
}
|
|
|
|
fn platform_key() -> &'static str {
|
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
|
{
|
|
"mac-arm64"
|
|
}
|
|
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
|
{
|
|
"mac-x64"
|
|
}
|
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
|
{
|
|
"linux64"
|
|
}
|
|
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
|
{
|
|
"win64"
|
|
}
|
|
#[cfg(not(any(
|
|
all(target_os = "macos", target_arch = "aarch64"),
|
|
all(target_os = "macos", target_arch = "x86_64"),
|
|
all(target_os = "linux", target_arch = "x86_64"),
|
|
all(target_os = "windows", target_arch = "x86_64"),
|
|
)))]
|
|
{
|
|
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
|
|
// can still be used for other commands like `connect`. The install path
|
|
// guards against this at runtime before calling platform_key().
|
|
panic!("Unsupported platform for Chrome for Testing download")
|
|
}
|
|
}
|
|
|
|
async fn fetch_download_url() -> Result<(String, String), String> {
|
|
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
|
|
|
let channel = body
|
|
.get("channels")
|
|
.and_then(|c| c.get("Stable"))
|
|
.ok_or("No Stable channel found in version info")?;
|
|
|
|
let version = channel
|
|
.get("version")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("No version string found")?
|
|
.to_string();
|
|
|
|
let platform = platform_key();
|
|
|
|
let url = channel
|
|
.get("downloads")
|
|
.and_then(|d| d.get("chrome"))
|
|
.and_then(|c| c.as_array())
|
|
.and_then(|arr| {
|
|
arr.iter().find_map(|entry| {
|
|
if entry.get("platform")?.as_str()? == platform {
|
|
Some(entry.get("url")?.as_str()?.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
|
|
|
|
Ok((version, url))
|
|
}
|
|
|
|
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
|
let resp = reqwest::get(url)
|
|
.await
|
|
.map_err(|e| format!("Download failed: {}", e))?;
|
|
|
|
let total = resp.content_length();
|
|
let mut bytes = Vec::new();
|
|
let mut stream = resp;
|
|
let mut downloaded: u64 = 0;
|
|
let mut last_pct: u64 = 0;
|
|
|
|
loop {
|
|
let chunk = stream
|
|
.chunk()
|
|
.await
|
|
.map_err(|e| format!("Download error: {}", e))?;
|
|
match chunk {
|
|
Some(data) => {
|
|
downloaded += data.len() as u64;
|
|
bytes.extend_from_slice(&data);
|
|
|
|
if let Some(total) = total {
|
|
let pct = (downloaded * 100) / total;
|
|
if pct >= last_pct + 5 {
|
|
last_pct = pct;
|
|
let mb = downloaded as f64 / 1_048_576.0;
|
|
let total_mb = total as f64 / 1_048_576.0;
|
|
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
|
|
let _ = io::stderr().flush();
|
|
}
|
|
}
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
|
|
eprintln!();
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
|
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
|
|
|
let cursor = io::Cursor::new(bytes);
|
|
let mut archive =
|
|
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
|
|
|
for i in 0..archive.len() {
|
|
let mut file = archive
|
|
.by_index(i)
|
|
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
|
|
|
let enclosed = match file.enclosed_name() {
|
|
Some(name) => name.to_owned(),
|
|
None => continue,
|
|
};
|
|
let raw_name = enclosed.to_string_lossy().to_string();
|
|
// Strip the top-level "chrome-<platform>/" directory from zip entries.
|
|
// On Windows, enclosed_name() normalizes paths to backslashes, so we
|
|
// must split on either separator.
|
|
let rel_path = raw_name
|
|
.strip_prefix("chrome-")
|
|
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string())
|
|
.unwrap_or(raw_name.clone());
|
|
|
|
if rel_path.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let out_path = dest.join(&rel_path);
|
|
|
|
// Defense-in-depth: ensure the resolved path is inside dest
|
|
if !out_path.starts_with(dest) {
|
|
continue;
|
|
}
|
|
|
|
if file.is_dir() {
|
|
fs::create_dir_all(&out_path)
|
|
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
|
|
} else {
|
|
if let Some(parent) = out_path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| {
|
|
format!("Failed to create parent dir {}: {}", parent.display(), e)
|
|
})?;
|
|
}
|
|
let mut out_file = fs::File::create(&out_path)
|
|
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
|
io::copy(&mut file, &mut out_file)
|
|
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Some(mode) = file.unix_mode() {
|
|
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run_install(with_deps: bool) {
|
|
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
|
eprintln!(
|
|
"{} Chrome for Testing does not provide Linux ARM64 builds.",
|
|
color::error_indicator()
|
|
);
|
|
eprintln!(" Install Chromium from your system package manager instead:");
|
|
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
|
|
eprintln!(" sudo dnf install chromium # Fedora");
|
|
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
|
|
exit(1);
|
|
}
|
|
|
|
let is_linux = cfg!(target_os = "linux");
|
|
|
|
if is_linux {
|
|
if with_deps {
|
|
install_linux_deps();
|
|
} else {
|
|
println!(
|
|
"{} Linux detected. If browser fails to launch, run:",
|
|
color::warning_indicator()
|
|
);
|
|
println!(" agent-browser install --with-deps");
|
|
println!();
|
|
}
|
|
}
|
|
|
|
println!("{}", color::cyan("Installing Chrome..."));
|
|
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap_or_else(|e| {
|
|
eprintln!(
|
|
"{} Failed to create runtime: {}",
|
|
color::error_indicator(),
|
|
e
|
|
);
|
|
exit(1);
|
|
});
|
|
|
|
let (version, url) = match rt.block_on(fetch_download_url()) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
};
|
|
|
|
let dest = get_browsers_dir().join(format!("chrome-{}", version));
|
|
|
|
if let Some(bin) = chrome_binary_in_dir(&dest) {
|
|
if bin.exists() {
|
|
println!(
|
|
"{} Chrome {} is already installed",
|
|
color::success_indicator(),
|
|
version
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
println!(" Downloading Chrome {} for {}", version, platform_key());
|
|
println!(" {}", url);
|
|
|
|
let bytes = match rt.block_on(download_bytes(&url)) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
};
|
|
|
|
match extract_zip(bytes, &dest) {
|
|
Ok(()) => {
|
|
println!(
|
|
"{} Chrome {} installed successfully",
|
|
color::success_indicator(),
|
|
version
|
|
);
|
|
println!(" Location: {}", dest.display());
|
|
|
|
if is_linux && !with_deps {
|
|
println!();
|
|
println!(
|
|
"{} If you see \"shared library\" errors when running, use:",
|
|
color::yellow("Note:")
|
|
);
|
|
println!(" agent-browser install --with-deps");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let _ = fs::remove_dir_all(&dest);
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
|
|
match status {
|
|
Ok(s) if s.success() => {
|
|
println!(
|
|
"{} System dependencies installed",
|
|
color::success_indicator()
|
|
)
|
|
}
|
|
Ok(_) => eprintln!(
|
|
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
|
color::warning_indicator()
|
|
),
|
|
Err(e) => eprintln!(
|
|
"{} Could not run install command: {}",
|
|
color::warning_indicator(),
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
fn install_linux_deps() {
|
|
println!("{}", color::cyan("Installing system dependencies..."));
|
|
|
|
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
|
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
|
|
// part of the 64-bit time_t transition. Using the old names can cause
|
|
// apt to propose removing hundreds of system packages to resolve
|
|
// conflicts. We check for the t64 variant first to avoid this.
|
|
let apt_deps: Vec<&str> = vec![
|
|
("libxcb-shm0", None),
|
|
("libx11-xcb1", None),
|
|
("libx11-6", None),
|
|
("libxcb1", None),
|
|
("libxext6", None),
|
|
("libxrandr2", None),
|
|
("libxcomposite1", None),
|
|
("libxcursor1", None),
|
|
("libxdamage1", None),
|
|
("libxfixes3", None),
|
|
("libxi6", None),
|
|
("libgtk-3-0", Some("libgtk-3-0t64")),
|
|
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
|
|
("libpango-1.0-0", Some("libpango-1.0-0t64")),
|
|
("libatk1.0-0", Some("libatk1.0-0t64")),
|
|
("libcairo-gobject2", Some("libcairo-gobject2t64")),
|
|
("libcairo2", Some("libcairo2t64")),
|
|
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
|
|
("libxrender1", None),
|
|
("libasound2", Some("libasound2t64")),
|
|
("libfreetype6", None),
|
|
("libfontconfig1", None),
|
|
("libdbus-1-3", Some("libdbus-1-3t64")),
|
|
("libnss3", None),
|
|
("libnspr4", None),
|
|
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
|
|
("libdrm2", None),
|
|
("libxkbcommon0", None),
|
|
("libatspi2.0-0", Some("libatspi2.0-0t64")),
|
|
("libcups2", Some("libcups2t64")),
|
|
("libxshmfence1", None),
|
|
("libgbm1", None),
|
|
// Fonts: without actual font files, pages render with missing glyphs
|
|
// (tofu). This is especially visible for CJK and emoji characters.
|
|
("fonts-noto-color-emoji", None),
|
|
("fonts-noto-cjk", None),
|
|
("fonts-freefont-ttf", None),
|
|
]
|
|
.into_iter()
|
|
.map(|(base, t64_variant)| {
|
|
if let Some(t64) = t64_variant {
|
|
if package_exists_apt(t64) {
|
|
return t64;
|
|
}
|
|
}
|
|
base
|
|
})
|
|
.collect();
|
|
|
|
("apt-get", apt_deps)
|
|
} else if which_exists("dnf") {
|
|
(
|
|
"dnf",
|
|
vec![
|
|
"nss",
|
|
"nspr",
|
|
"atk",
|
|
"at-spi2-atk",
|
|
"cups-libs",
|
|
"libdrm",
|
|
"libXcomposite",
|
|
"libXdamage",
|
|
"libXrandr",
|
|
"mesa-libgbm",
|
|
"pango",
|
|
"alsa-lib",
|
|
"libxkbcommon",
|
|
"libxcb",
|
|
"libX11-xcb",
|
|
"libX11",
|
|
"libXext",
|
|
"libXcursor",
|
|
"libXfixes",
|
|
"libXi",
|
|
"gtk3",
|
|
"cairo-gobject",
|
|
// Fonts
|
|
"google-noto-cjk-fonts",
|
|
"google-noto-emoji-color-fonts",
|
|
"liberation-fonts",
|
|
],
|
|
)
|
|
} else if which_exists("yum") {
|
|
(
|
|
"yum",
|
|
vec![
|
|
"nss",
|
|
"nspr",
|
|
"atk",
|
|
"at-spi2-atk",
|
|
"cups-libs",
|
|
"libdrm",
|
|
"libXcomposite",
|
|
"libXdamage",
|
|
"libXrandr",
|
|
"mesa-libgbm",
|
|
"pango",
|
|
"alsa-lib",
|
|
"libxkbcommon",
|
|
// Fonts
|
|
"google-noto-cjk-fonts",
|
|
"liberation-fonts",
|
|
],
|
|
)
|
|
} else {
|
|
eprintln!(
|
|
"{} No supported package manager found (apt-get, dnf, or yum)",
|
|
color::error_indicator()
|
|
);
|
|
exit(1);
|
|
};
|
|
|
|
if pkg_mgr == "apt-get" {
|
|
// Run apt-get update first
|
|
println!("Running: sudo apt-get update");
|
|
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
|
|
|
|
match update_status {
|
|
Ok(s) if !s.success() => {
|
|
eprintln!(
|
|
"{} apt-get update failed. Continuing with existing package lists.",
|
|
color::warning_indicator()
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"{} Could not run apt-get update: {}",
|
|
color::warning_indicator(),
|
|
e
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Simulate the install first to detect if apt would remove any
|
|
// packages. This prevents the catastrophic scenario where installing
|
|
// these libraries triggers removal of hundreds of system packages
|
|
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
|
|
// t64 transition).
|
|
println!("Checking for conflicts...");
|
|
let sim_output = Command::new("sudo")
|
|
.args(["apt-get", "install", "--simulate"])
|
|
.args(&deps)
|
|
.output();
|
|
|
|
match sim_output {
|
|
Ok(output) => {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let combined = format!("{}\n{}", stdout, stderr);
|
|
|
|
// Count packages that would be removed
|
|
let removals: Vec<&str> = combined
|
|
.lines()
|
|
.filter(|line| line.starts_with("Remv "))
|
|
.collect();
|
|
|
|
if !removals.is_empty() {
|
|
eprintln!(
|
|
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
|
|
color::error_indicator(),
|
|
removals.len()
|
|
);
|
|
eprintln!(
|
|
" This usually means some package names have changed on your system"
|
|
);
|
|
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
|
|
eprintln!();
|
|
eprintln!(" Packages that would be removed:");
|
|
for line in removals.iter().take(20) {
|
|
eprintln!(" {}", line);
|
|
}
|
|
if removals.len() > 20 {
|
|
eprintln!(" ... and {} more", removals.len() - 20);
|
|
}
|
|
eprintln!();
|
|
eprintln!(" To install dependencies manually, run:");
|
|
eprintln!(" sudo apt-get install {}", deps.join(" "));
|
|
eprintln!();
|
|
eprintln!(" Review the apt output carefully before confirming.");
|
|
exit(1);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"{} Could not simulate install ({}). Proceeding with caution.",
|
|
color::warning_indicator(),
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Safe to proceed: no removals detected
|
|
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
|
|
println!("Running: {}", install_cmd);
|
|
let status = Command::new("sudo")
|
|
.args(["apt-get", "install", "-y"])
|
|
.args(&deps)
|
|
.status();
|
|
|
|
report_install_status(status);
|
|
} else {
|
|
// dnf / yum path — these package managers do not remove packages
|
|
// during install, so the simulate-first guard is not needed.
|
|
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
|
|
println!("Running: {}", install_cmd);
|
|
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
|
|
|
report_install_status(status);
|
|
}
|
|
}
|
|
|
|
fn which_exists(cmd: &str) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
Command::new("which")
|
|
.arg(cmd)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
Command::new("where")
|
|
.arg(cmd)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
fn package_exists_apt(pkg: &str) -> bool {
|
|
Command::new("apt-cache")
|
|
.arg("show")
|
|
.arg(pkg)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dashboard install
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn get_dashboard_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".agent-browser")
|
|
.join("dashboard")
|
|
}
|
|
|
|
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
fn dashboard_download_url() -> String {
|
|
format!(
|
|
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
|
|
DASHBOARD_VERSION
|
|
)
|
|
}
|
|
|
|
pub fn run_dashboard_install() {
|
|
println!("{}", color::cyan("Installing dashboard..."));
|
|
|
|
let dest = get_dashboard_dir();
|
|
|
|
if dest.join("index.html").exists() {
|
|
println!(
|
|
"{} Dashboard is already installed at {}",
|
|
color::success_indicator(),
|
|
dest.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let url = dashboard_download_url();
|
|
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
|
println!(" {}", url);
|
|
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap_or_else(|e| {
|
|
eprintln!(
|
|
"{} Failed to create runtime: {}",
|
|
color::error_indicator(),
|
|
e
|
|
);
|
|
exit(1);
|
|
});
|
|
|
|
let bytes = match rt.block_on(download_bytes(&url)) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
eprintln!(" The dashboard may not be available for this version yet.");
|
|
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
|
|
exit(1);
|
|
}
|
|
};
|
|
|
|
match extract_dashboard_zip(bytes, &dest) {
|
|
Ok(()) => {
|
|
println!(
|
|
"{} Dashboard v{} installed successfully",
|
|
color::success_indicator(),
|
|
DASHBOARD_VERSION
|
|
);
|
|
println!(" Location: {}", dest.display());
|
|
}
|
|
Err(e) => {
|
|
let _ = fs::remove_dir_all(&dest);
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extract_dashboard_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
|
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
|
|
|
let cursor = io::Cursor::new(bytes);
|
|
let mut archive =
|
|
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
|
|
|
for i in 0..archive.len() {
|
|
let mut file = archive
|
|
.by_index(i)
|
|
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
|
|
|
let enclosed = match file.enclosed_name() {
|
|
Some(name) => name.to_owned(),
|
|
None => continue,
|
|
};
|
|
let rel_path = enclosed.to_string_lossy().to_string();
|
|
|
|
if rel_path.is_empty() || file.is_dir() {
|
|
if file.is_dir() {
|
|
let out_dir = dest.join(&rel_path);
|
|
let _ = fs::create_dir_all(&out_dir);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let out_path = dest.join(&rel_path);
|
|
if !out_path.starts_with(dest) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(parent) = out_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?;
|
|
}
|
|
let mut out_file = fs::File::create(&out_path)
|
|
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
|
io::copy(&mut file, &mut out_file)
|
|
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|