captchaforge 0.2.34

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Android WebView + iOS WKWebView captcha solving over CDP-over-USB.
//!
//! Modern mobile apps embed CAPTCHA challenges inside a WebView.
//! Both Android (`WebView.setWebContentsDebuggingEnabled(true)`) and
//! iOS (Safari Inspector) expose a CDP-over-USB endpoint. This
//! module wraps the connection setup so a captchaforge chain can
//! solve a mobile-app captcha exactly like a desktop one.
//!
//! Flow:
//! 1. Operator enables WebView debugging on the device.
//! 2. `MobileWebViewBridge::connect_android(serial)` (Android via
//!    `adb forward tcp:9222 localabstract:chrome_devtools_remote`)
//!    or `connect_ios(udid)` (iOS via `ios_safari_inspector` tool).
//! 3. The returned `chromiumoxide::Page` plugs into the regular
//!    `CaptchaSolverChain::solve` path.
//!
//! This module ships the connection-shape contract — actual
//! adb/ios-tools shell-out lives behind an opt-in feature flag in
//! a future release (each is platform-specific and brings a
//! separate process tree). The contract here lets the bench /
//! integration tests stub the connection.

use serde::{Deserialize, Serialize};

/// Where the mobile WebView lives.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MobileTarget {
    /// Android device with USB debugging on. `serial` is the
    /// device id from `adb devices`.
    Android { serial: String },
    /// iOS device with Web Inspector enabled. `udid` is the
    /// Universal Device IDentifier shown by `ideviceinfo`.
    Ios { udid: String },
    /// Generic remote — operator already set up a CDP endpoint and
    /// just wants captchaforge to use it.
    RemoteCdp { url: String },
}

/// Connection metadata returned after a successful bridge setup.
#[derive(Debug, Clone)]
pub struct MobileBridge {
    pub target: MobileTarget,
    /// CDP endpoint reachable from the host (`ws://localhost:9222/...`).
    pub cdp_endpoint: String,
}

impl MobileBridge {
    /// Stub the bridge setup for testing / dry-run. Returns the
    /// `MobileBridge` without performing any platform shell-out.
    pub fn stub_for(target: MobileTarget, cdp_endpoint: impl Into<String>) -> Self {
        Self {
            target,
            cdp_endpoint: cdp_endpoint.into(),
        }
    }

    /// Validate the CDP endpoint URL. Returns Err with a reason
    /// when the URL isn't a `ws://` / `wss://` scheme or is missing
    /// a host. Catches operator mistakes (passing http URLs, empty
    /// strings) before chromiumoxide attempts a doomed connection.
    pub fn validate(&self) -> Result<(), String> {
        let parsed = url::Url::parse(&self.cdp_endpoint)
            .map_err(|e| format!("invalid CDP endpoint URL: {e}"))?;
        match parsed.scheme() {
            "ws" | "wss" => {}
            other => return Err(format!("CDP endpoint scheme must be ws/wss; got {other:?}")),
        }
        if parsed.host().is_none() {
            return Err("CDP endpoint missing host".into());
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn android_target_serialises_snake_case() {
        let t = MobileTarget::Android {
            serial: "ABC123".into(),
        };
        let json = serde_json::to_string(&t).unwrap();
        assert!(json.contains("\"android\""));
        assert!(json.contains("\"serial\""));
    }

    #[test]
    fn ios_target_round_trips() {
        let t = MobileTarget::Ios {
            udid: "AAAA-BBBB-CCCC".into(),
        };
        let json = serde_json::to_string(&t).unwrap();
        let back: MobileTarget = serde_json::from_str(&json).unwrap();
        match back {
            MobileTarget::Ios { udid } => assert_eq!(udid, "AAAA-BBBB-CCCC"),
            _ => panic!("expected Ios variant after round-trip"),
        }
    }

    #[test]
    fn validate_rejects_http_scheme() {
        let b = MobileBridge::stub_for(
            MobileTarget::RemoteCdp {
                url: "http://example.com".into(),
            },
            "http://localhost:9222",
        );
        assert!(b.validate().is_err());
    }

    #[test]
    fn validate_accepts_ws_endpoint() {
        let b = MobileBridge::stub_for(
            MobileTarget::Android {
                serial: "x".into(),
            },
            "ws://localhost:9222/devtools/browser/abc",
        );
        assert!(b.validate().is_ok());
    }

    #[test]
    fn validate_accepts_wss_endpoint() {
        let b = MobileBridge::stub_for(
            MobileTarget::Ios {
                udid: "y".into(),
            },
            "wss://inspector.local:9222/",
        );
        assert!(b.validate().is_ok());
    }

    #[test]
    fn validate_rejects_empty_endpoint() {
        let b = MobileBridge::stub_for(
            MobileTarget::Android {
                serial: "x".into(),
            },
            "",
        );
        assert!(b.validate().is_err());
    }
}