use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use crate::error::BlinkError;
pub const WS_PROBE_MAX_RETRIES: u32 = 20;
pub const WS_PROBE_INTERVAL_MS: u64 = 300;
#[cfg(target_os = "macos")]
const CANDIDATES: &[&str] = &[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"google-chrome",
"chromium",
"chromium-browser",
"chrome",
];
#[cfg(not(target_os = "macos"))]
const CANDIDATES: &[&str] = &[
"chromium-browser",
"chromium",
"google-chrome",
"google-chrome-stable",
"chrome",
];
pub fn find_chromium() -> Option<PathBuf> {
for candidate in CANDIDATES {
let path = PathBuf::from(candidate);
if path.is_absolute() {
if path.exists() {
return Some(path);
}
continue;
}
if let Ok(resolved) = which_binary(candidate) {
return Some(resolved);
}
}
None
}
fn which_binary(name: &str) -> Result<PathBuf, ()> {
let path_var = std::env::var_os("PATH").ok_or(())?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = candidate.metadata()
&& meta.permissions().mode() & 0o111 != 0
{
return Ok(candidate);
}
}
#[cfg(not(unix))]
return Ok(candidate);
}
}
Err(())
}
pub fn pick_free_port() -> Result<u16, BlinkError> {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(BlinkError::PortProbe)?;
let port = listener.local_addr().map_err(BlinkError::PortProbe)?.port();
drop(listener); Ok(port)
}
pub fn spawn_headless(
chromium: &Path,
port: u16,
user_data_dir: &Path,
) -> Result<Child, BlinkError> {
tracing::debug!(
binary = %chromium.display(),
port,
user_data_dir = %user_data_dir.display(),
"spawning headless Chromium"
);
Command::new(chromium)
.args([
"--headless=new",
&format!("--remote-debugging-port={port}"),
&format!("--user-data-dir={}", user_data_dir.display()),
"--no-first-run",
"--no-default-browser-check",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-background-networking",
"--log-level=3", "about:blank",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(BlinkError::SpawnFailed)
}
pub fn probe_ws_url(port: u16, max_attempts: u32, delay: Duration) -> Result<String, BlinkError> {
let url = format!("http://127.0.0.1:{port}/json/version");
let mut last_err = String::new();
for attempt in 0..max_attempts {
std::thread::sleep(delay);
tracing::debug!(attempt, port, "probing CDP endpoint");
match ureq::get(&url).call() {
Ok(resp) => {
let body: serde_json::Value = resp
.into_body()
.read_json()
.map_err(|e| BlinkError::Protocol(format!("CDP version JSON parse: {e}")))?;
let ws_url = body
.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.ok_or_else(|| {
BlinkError::Protocol("missing webSocketDebuggerUrl in /json/version".into())
})?;
tracing::debug!(ws_url, "CDP ready");
return Ok(ws_url.to_owned());
}
Err(e) => {
last_err = e.to_string();
tracing::debug!(attempt, error = %e, "CDP not ready yet");
}
}
}
Err(BlinkError::ProbeFailed {
port,
source: last_err.into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_free_port_returns_nonzero() {
let port = pick_free_port().expect("OS should provide an ephemeral port");
assert_ne!(port, 0, "port must be non-zero");
}
#[test]
fn pick_free_port_returns_different_ports_across_calls_usually() {
let p1 = pick_free_port().expect("first pick failed");
let p2 = pick_free_port().expect("second pick failed");
assert_ne!(p1, 0);
assert_ne!(p2, 0);
let _ = p1 != p2; }
#[test]
fn pick_free_port_within_ephemeral_range() {
let port = pick_free_port().expect("OS should provide an ephemeral port");
assert!(port >= 1024, "expected ephemeral port >= 1024, got {port}");
}
#[test]
fn find_chromium_returns_none_when_path_empty() {
#[cfg(not(target_os = "macos"))]
{
unsafe { std::env::set_var("PATH", "") };
let result = find_chromium();
assert!(
result.is_none(),
"expected None with empty PATH, got {result:?}"
);
}
#[cfg(target_os = "macos")]
{
let _ = find_chromium();
}
}
}