use base64::Engine;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileScreenshotRequest {
pub image_bytes: Vec<u8>,
pub instruction: String,
#[serde(default)]
pub vendor_hint: Option<String>,
#[serde(default)]
pub grid: Option<(u32, u32)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileScreenshotResult {
pub solution: String,
pub confidence: f32,
pub time_ms: u64,
#[serde(default)]
pub tokens_used: Option<u64>,
}
#[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),
}
pub const MAX_SCREENSHOT_BYTES: usize = 8 * 1024 * 1024;
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))
}
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
)
}
#[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();
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)));
}
}