captchaforge 0.2.38

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for Firefox + BiDi-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / BiDi fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Browser launch via rustenium Firefox (replaces runtime_headless).
//!
//! CLI, examples, and bindings should use [`drive_browser`] instead of
//! hand-rolling `captchaforge::browser::launch_firefox` + handler tasks.

use anyhow::{Context, Result};

use runtime_foxdriver::{launch_firefox_self_managed, FoxBrowserConfig, Page, ProxyConfig};

use crate::StealthProfile;

/// Options for [`drive_browser`].
#[derive(Debug, Clone)]
pub struct BrowserDriveOptions {
    /// When false, launch a visible window.
    pub headless: bool,
    /// Disable chromium sandbox (no-op for Firefox, kept for API compat).
    pub no_sandbox: bool,
    /// Optional launch-time User-Agent override. Used by wrappers that must keep
    /// the CAPTCHA-solving browser coherent with a role-bound external session.
    pub user_agent: Option<String>,
    /// Optional Accept-Language value, translated into Firefox's
    /// `intl.accept_languages` pref before first navigation.
    pub accept_language: Option<String>,
    /// Optional proxy URL for the browser session.
    pub proxy_url: Option<String>,
    /// Optional coherent Guise persona applied before first navigation.
    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,
        }
    }
}

/// Build launch config aligned with the old runtime-headless shape.
#[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"))
}

/// Launch Firefox, navigate to `url`, run `f` with the live page, then tear down.
///
/// Uses [`launch_firefox_self_managed`] — foxdriver owns the spawn and polls the
/// debug port until it is live, instead of rustenium's fixed 500 ms post-spawn
/// sleep. That fixed sleep races any slow-binding build (it cost the reynard gate
/// a `ConnectionRefused` panic); the readiness poll makes every launch on this
/// path robust. The binary is resolved from `PATH`/standard locations when no
/// `executable_path` is set, so existing PATH-relying callers are unaffected.
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);
    }
}