captchaforge 0.2.32

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Mobile-app captcha screenshot solving.
//!
//! Native mobile apps (Android / iOS) embed FunCaptcha, hCaptcha
//! mobile SDK, and reCAPTCHA mobile SDK in their own WebView /
//! native view. Operators who instrument the app + capture the
//! captcha screenshot can pipe the screenshot here for VLM-based
//! solving.
//!
//! Flow:
//! 1. App-side capture: screenshot the captcha view → PNG/JPEG.
//! 2. Send the screenshot bytes (plus the prompt for the VLM
//!    describing what the captcha asks for) to
//!    [`solve_screenshot`].
//! 3. Result: text solution (e.g. "select all squares with a
//!    bicycle" → "[0, 3, 5]" indices) or `Err` on failure.
//!
//! This module is the VLM-only path — it does NOT need a chromium
//! Page handle, so it's usable from any host (Android NDK, iOS
//! Swift via the captchaforge-c crate, the HTTP `serve` API, …).

use base64::Engine;
use serde::{Deserialize, Serialize};

/// One screenshot solve request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileScreenshotRequest {
    /// PNG/JPEG bytes of the captcha view.
    pub image_bytes: Vec<u8>,
    /// What the captcha asks the user to do. Free-text prompt the
    /// VLM consumes. Example: "Select all squares with a bicycle".
    pub instruction: String,
    /// Optional vendor hint — improves the VLM prompt template.
    /// Match values from `community.toml` (e.g. `"funcaptcha"`,
    /// `"hcaptcha_mobile"`).
    #[serde(default)]
    pub vendor_hint: Option<String>,
    /// Optional grid layout for click-puzzles. `Some((rows, cols))`
    /// instructs the VLM to return cell indices instead of free-form
    /// coordinates.
    #[serde(default)]
    pub grid: Option<(u32, u32)>,
}

/// Solver outcome.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileScreenshotResult {
    /// Free-form text the VLM returned. Format depends on the
    /// `grid` field of the request: `[0,3,5]` for grid puzzles,
    /// `"abc123"` for text-OCR, etc.
    pub solution: String,
    /// VLM-reported confidence in `0.0..1.0`.
    pub confidence: f32,
    /// Wall-clock duration of the solve, in milliseconds.
    pub time_ms: u64,
    /// Cost in tokens / credits (operator dashboards bill on this).
    #[serde(default)]
    pub tokens_used: Option<u64>,
}

/// Errors specific to the mobile-screenshot pipeline.
#[derive(Debug, thiserror::Error)]
pub enum MobileScreenshotError {
    #[error("image bytes are empty")]
    EmptyImage,
    #[error("image too large: {0} bytes (max {1})")]
    ImageTooLarge(usize, usize),
    #[error("VLM endpoint unreachable: {0}")]
    EndpointUnreachable(String),
    #[error("VLM returned no usable solution: {0}")]
    NoSolution(String),
}

/// Maximum image size the helper accepts. Mobile captchas are
/// typically <500 KiB; 8 MiB is generous + bounds memory.
pub const MAX_SCREENSHOT_BYTES: usize = 8 * 1024 * 1024;

/// Pre-flight validation. Caller dispatches the VLM request after
/// this passes. Returns Ok(base64-encoded image) so the caller
/// hands the VLM-ready blob straight to the model API.
pub fn prepare(req: &MobileScreenshotRequest) -> Result<String, MobileScreenshotError> {
    if req.image_bytes.is_empty() {
        return Err(MobileScreenshotError::EmptyImage);
    }
    if req.image_bytes.len() > MAX_SCREENSHOT_BYTES {
        return Err(MobileScreenshotError::ImageTooLarge(
            req.image_bytes.len(),
            MAX_SCREENSHOT_BYTES,
        ));
    }
    Ok(base64::engine::general_purpose::STANDARD.encode(&req.image_bytes))
}

/// Synthesise the prompt template the VLM consumes. Operators who
/// want a custom prompt can skip this and build their own.
pub fn build_prompt(req: &MobileScreenshotRequest) -> String {
    let vendor_hint = req
        .vendor_hint
        .as_deref()
        .map(|v| format!(" (vendor: {v})"))
        .unwrap_or_default();
    let grid_hint = req
        .grid
        .map(|(r, c)| format!(" The image is a {r}-row × {c}-column grid; respond with a JSON array of 0-indexed cell indices."))
        .unwrap_or_else(|| " Respond with the literal answer text only.".to_string());
    format!(
        "You are solving a mobile-app CAPTCHA{vendor_hint}. Instruction: {}.{grid_hint}",
        req.instruction
    )
}

// thiserror is intentionally a direct captchaforge dep so the
// MobileScreenshotError above can derive Error without an extra
// crate boundary.

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

    fn req(instruction: &str, bytes: usize) -> MobileScreenshotRequest {
        MobileScreenshotRequest {
            image_bytes: vec![0u8; bytes],
            instruction: instruction.into(),
            vendor_hint: None,
            grid: None,
        }
    }

    #[test]
    fn prepare_rejects_empty_image() {
        let r = req("solve", 0);
        match prepare(&r) {
            Err(MobileScreenshotError::EmptyImage) => {}
            other => panic!("expected EmptyImage, got {other:?}"),
        }
    }

    #[test]
    fn prepare_rejects_oversized_image() {
        let r = req("solve", MAX_SCREENSHOT_BYTES + 1);
        match prepare(&r) {
            Err(MobileScreenshotError::ImageTooLarge(n, max)) => {
                assert_eq!(n, MAX_SCREENSHOT_BYTES + 1);
                assert_eq!(max, MAX_SCREENSHOT_BYTES);
            }
            other => panic!("expected ImageTooLarge, got {other:?}"),
        }
    }

    #[test]
    fn prepare_encodes_image_at_size_cap() {
        let r = req("solve", MAX_SCREENSHOT_BYTES);
        let b64 = prepare(&r).unwrap();
        // base64 of N bytes is ceil(N / 3) * 4 chars.
        let expected_len = MAX_SCREENSHOT_BYTES.div_ceil(3) * 4;
        assert_eq!(b64.len(), expected_len);
    }

    #[test]
    fn build_prompt_includes_instruction_verbatim() {
        let r = MobileScreenshotRequest {
            image_bytes: vec![0u8; 16],
            instruction: "Select bicycles".into(),
            vendor_hint: Some("hcaptcha_mobile".into()),
            grid: Some((3, 3)),
        };
        let p = build_prompt(&r);
        assert!(p.contains("Select bicycles"));
        assert!(p.contains("hcaptcha_mobile"));
        assert!(p.contains("3-row × 3-column"));
    }

    #[test]
    fn build_prompt_omits_grid_hint_when_absent() {
        let r = MobileScreenshotRequest {
            image_bytes: vec![0u8; 16],
            instruction: "OCR text".into(),
            vendor_hint: None,
            grid: None,
        };
        let p = build_prompt(&r);
        assert!(!p.contains("grid"));
        assert!(p.contains("literal answer text"));
    }

    #[test]
    fn request_round_trips_through_serde() {
        let r = MobileScreenshotRequest {
            image_bytes: vec![1, 2, 3],
            instruction: "x".into(),
            vendor_hint: Some("v".into()),
            grid: Some((4, 4)),
        };
        let json = serde_json::to_string(&r).unwrap();
        let back: MobileScreenshotRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(back.image_bytes, r.image_bytes);
        assert_eq!(back.grid, Some((4, 4)));
    }
}