use anyhow::{Context, Result};
use runtime_foxdriver::{launch_firefox_self_managed, FoxBrowserConfig, Page, ProxyConfig};
use crate::StealthProfile;
#[derive(Debug, Clone)]
pub struct BrowserDriveOptions {
pub headless: bool,
pub no_sandbox: bool,
pub user_agent: Option<String>,
pub accept_language: Option<String>,
pub proxy_url: Option<String>,
pub profile: Option<StealthProfile>,
}
impl Default for BrowserDriveOptions {
fn default() -> Self {
Self {
headless: true,
no_sandbox: true,
user_agent: None,
accept_language: None,
proxy_url: None,
profile: None,
}
}
}
#[must_use]
pub fn launch_options(opts: &BrowserDriveOptions) -> FoxBrowserConfig {
let profile_overrides = opts.profile.as_ref().map(guise::profile_to_overrides);
let user_agent = opts
.user_agent
.clone()
.or_else(|| profile_overrides.as_ref().map(|p| p.user_agent.clone()));
let mut user_js = String::new();
if let Some(accept_language) = firefox_accept_language_pref(opts.accept_language.as_deref()) {
user_js.push_str(&accept_language);
}
let mut config = FoxBrowserConfig {
headless: opts.headless,
user_agent,
user_js_content: if user_js.is_empty() { None } else { Some(user_js) },
..Default::default()
};
if let Some(profile) = profile_overrides.as_ref() {
config.viewport_width = profile.screen_width;
config.viewport_height = profile.screen_height;
}
config
}
fn launch_options_with_proxy(opts: &BrowserDriveOptions) -> Result<FoxBrowserConfig> {
let mut config = launch_options(opts);
if let Some(proxy_url) = opts.proxy_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
config.proxy = Some(
ProxyConfig::from_url(proxy_url)
.map_err(|err| anyhow::anyhow!("parse browser proxy URL {proxy_url:?}: {err}"))?,
);
}
Ok(config)
}
fn firefox_accept_language_pref(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() {
return None;
}
let langs: Vec<String> = value
.split(',')
.filter_map(|part| part.split(';').next().map(str::trim))
.filter(|part| !part.is_empty())
.map(ToOwned::to_owned)
.collect();
if langs.is_empty() {
return None;
}
let joined = langs.join(", ");
let escaped = serde_json::to_string(&joined).ok()?;
Some(format!("user_pref(\"intl.accept_languages\", {escaped});\n"))
}
pub async fn drive_browser<F, Fut, T>(url: &str, opts: BrowserDriveOptions, f: F) -> Result<T>
where
F: FnOnce(Page) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let page = launch_firefox_self_managed(launch_options_with_proxy(&opts)?)
.await
.map_err(|e| anyhow::anyhow!("launch firefox (is it installed and on PATH?): {e}"))?;
crate::prepare_page(&page, opts.profile)
.await
.context("prepare browser before first navigation")?;
tokio::time::timeout(std::time::Duration::from_secs(30), page.goto(url))
.await
.map_err(|_| anyhow::anyhow!("navigate to {url} timed out after 30s"))?
.with_context(|| format!("navigate to {url}"))?;
f(page).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_options_headless_maps_correctly() {
let opts = launch_options(&BrowserDriveOptions {
headless: true,
..BrowserDriveOptions::default()
});
assert!(opts.headless);
}
#[test]
fn launch_options_headful_maps_correctly() {
let opts = launch_options(&BrowserDriveOptions {
headless: false,
..BrowserDriveOptions::default()
});
assert!(!opts.headless);
}
#[test]
fn launch_options_apply_user_agent_and_accept_language() {
let opts = launch_options(&BrowserDriveOptions {
user_agent: Some("Mozilla/5.0 Firefox/133.0".into()),
accept_language: Some("en-US,en;q=0.5".into()),
..BrowserDriveOptions::default()
});
assert_eq!(opts.user_agent.as_deref(), Some("Mozilla/5.0 Firefox/133.0"));
let user_js = opts.user_js_content.as_deref().unwrap_or_default();
assert!(user_js.contains("intl.accept_languages"));
assert!(user_js.contains("en-US, en"));
}
#[test]
fn launch_options_keep_default_viewport_without_profile() {
let opts = launch_options(&BrowserDriveOptions::default());
let defaults = FoxBrowserConfig::default();
assert_eq!(opts.viewport_width, defaults.viewport_width);
assert_eq!(opts.viewport_height, defaults.viewport_height);
}
}