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
//! Android WebView + iOS WKWebView captcha solving over BiDi-over-USB.
//!
//! Modern mobile apps embed CAPTCHA challenges inside a WebView.
//! Both Android (`WebView.setWebContentsDebuggingEnabled(true)`) and
//! iOS (Safari Inspector) expose a debugging 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 `captchaforge::browser::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 BiDi endpoint and
    /// just wants captchaforge to use it.
    #[serde(rename = "remote_bidi")]
    RemoteBiDi { url: String },
}

/// Connection metadata returned after a successful bridge setup.
#[derive(Debug, Clone)]
pub struct MobileBridge {
    pub target: MobileTarget,
    /// BiDi endpoint reachable from the host (`ws://localhost:9222/...`).
    pub bidi_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, bidi_endpoint: impl Into<String>) -> Self {
        Self {
            target,
            bidi_endpoint: bidi_endpoint.into(),
        }
    }

    /// Validate the BiDi 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 rustenium attempts a doomed connection.
    pub fn validate(&self) -> Result<(), String> {
        let parsed = url::Url::parse(&self.bidi_endpoint)
            .map_err(|e| format!("invalid BiDi endpoint URL: {e}"))?;
        match parsed.scheme() {
            "ws" | "wss" => {}
            other => return Err(format!("BiDi endpoint scheme must be ws/wss; got {other:?}")),
        }
        if parsed.host().is_none() {
            return Err("BiDi 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::RemoteBiDi {
                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/session",
        );
        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/session",
        );
        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());
    }

    #[test]
    fn remote_bidi_target_serialises() {
        let t = MobileTarget::RemoteBiDi {
            url: "ws://host:9222".into(),
        };
        let json = serde_json::to_string(&t).unwrap();
        assert!(json.contains("\"remote_bidi\""));
        assert!(json.contains("\"url\""));
    }
}