use std::time::Duration;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::time::Instant;
use zendriver_transport::SessionHandle;
use crate::click::click_at;
use crate::detection::BoundingBox;
use crate::error::CloudflareError;
#[derive(Debug, Clone)]
pub enum ClearanceOutcome {
TokenAcquired(String),
ChallengeGone,
TimedOut { saw_challenge: bool },
}
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Debug)]
pub struct CloudflareBypass<'a> {
pub(crate) session: &'a SessionHandle,
pub(crate) poll_interval: Duration,
}
impl<'a> CloudflareBypass<'a> {
pub fn new(session: &'a SessionHandle) -> Self {
Self {
session,
poll_interval: DEFAULT_POLL_INTERVAL,
}
}
#[must_use]
pub fn poll_interval(mut self, dur: Duration) -> Self {
self.poll_interval = dur;
self
}
pub async fn wait_for_clearance(
self,
timeout: Duration,
) -> Result<ClearanceOutcome, CloudflareError> {
let deadline = Instant::now() + timeout;
let mut clicked = false;
let mut ever_seen_markers = false;
let mut stall_ticks: u32 = 0;
let mut warned_stall = false;
loop {
let state = poll_state(self.session).await?;
if state.has_markers {
ever_seen_markers = true;
}
if let Some(token) = state.token {
return Ok(ClearanceOutcome::TokenAcquired(token));
}
match state.bbox {
Some(bbox) if !clicked => {
let click_x = bbox.x + bbox.width * 0.15;
let click_y = bbox.y + bbox.height * 0.50;
click_at(self.session, click_x, click_y).await?;
clicked = true;
}
None if clicked => {
return Ok(ClearanceOutcome::ChallengeGone);
}
_ => {
stall_ticks += 1;
if stall_ticks == 10 && !warned_stall {
tracing::warn!(
poll_interval_ms = self.poll_interval.as_millis() as u64,
"cloudflare clearance stalled — is BrowserBuilder::stealth enabled?"
);
warned_stall = true;
}
}
}
if Instant::now() >= deadline {
return Ok(ClearanceOutcome::TimedOut {
saw_challenge: ever_seen_markers,
});
}
tokio::select! {
() = tokio::time::sleep(self.poll_interval) => {}
() = tokio::time::sleep_until(deadline) => {}
}
}
}
}
#[derive(Debug, Deserialize)]
struct PollState {
#[serde(default)]
token: Option<String>,
#[serde(default)]
bbox: Option<BoundingBox>,
#[serde(default, rename = "hasMarkers")]
has_markers: bool,
}
const POLL_JS: &str = r#"
(function () {
function findBbox(root) {
var iframes = root.querySelectorAll ? root.querySelectorAll("iframe") : [];
for (var i = 0; i < iframes.length; i++) {
var f = iframes[i];
if (f.src && f.src.includes("challenges.cloudflare.com")) {
var r = f.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
}
}
var all = root.querySelectorAll ? root.querySelectorAll("*") : [];
for (var j = 0; j < all.length; j++) {
if (all[j].shadowRoot) {
var sub = findBbox(all[j].shadowRoot);
if (sub) return sub;
}
}
return null;
}
var bbox = findBbox(document);
var input =
document.querySelector('[name="cf-turnstile-response"]') ||
document.querySelector('[name="cf_challenge_response"]');
var token = (input && input.value) ? input.value : null;
var hasContainer = !!document.querySelector('.cf-turnstile, .turnstile, [data-sitekey]');
var hasMarkers = hasContainer || !!input || !!bbox;
return { token: token, bbox: bbox, hasMarkers: hasMarkers };
})()
"#;
async fn poll_state(session: &SessionHandle) -> Result<PollState, CloudflareError> {
let res = session
.call(
"Runtime.evaluate",
json!({
"expression": POLL_JS,
"returnByValue": true,
"awaitPromise": true,
}),
)
.await?;
if let Some(details) = res.get("exceptionDetails") {
let msg = details
.get("exception")
.and_then(|e| e.get("description"))
.and_then(|d| d.as_str())
.unwrap_or("unknown")
.to_string();
return Err(CloudflareError::JsError(msg));
}
let value = res
.get("result")
.and_then(|r| r.get("value"))
.cloned()
.unwrap_or(Value::Null);
serde_json::from_value(value)
.map_err(|e| CloudflareError::JsError(format!("invalid poll payload: {e}")))
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn wait_for_clearance_clicks_at_bbox_offset_then_returns_token() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
const BBOX_X: f64 = 100.0;
const BBOX_Y: f64 = 200.0;
const BBOX_W: f64 = 60.0;
const BBOX_H: f64 = 40.0;
const EXPECTED_CLICK_X: f64 = BBOX_X + BBOX_W * 0.15;
const EXPECTED_CLICK_Y: f64 = BBOX_Y + BBOX_H * 0.50;
let fut = tokio::spawn({
let s = sess.clone();
async move {
let b = CloudflareBypass::new(&s).poll_interval(Duration::from_millis(1));
b.wait_for_clearance(Duration::from_secs(5)).await
}
});
let id_poll1 = mock.expect_cmd("Runtime.evaluate").await;
assert!(
mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.contains("challenges.cloudflare.com"),
"poll eval should walk for challenges.cloudflare.com iframe"
);
assert!(
mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.contains("cf-turnstile-response"),
"poll eval should look at the cf-turnstile-response input"
);
mock.reply(
id_poll1,
json!({
"result": {
"type": "object",
"value": {
"token": null,
"bbox": { "x": BBOX_X, "y": BBOX_Y, "width": BBOX_W, "height": BBOX_H },
"hasMarkers": true,
}
}
}),
)
.await;
let id_move = mock.expect_cmd("Input.dispatchMouseEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "mouseMoved");
assert_eq!(sent["params"]["x"], EXPECTED_CLICK_X);
assert_eq!(sent["params"]["y"], EXPECTED_CLICK_Y);
mock.reply(id_move, json!({})).await;
let id_press = mock.expect_cmd("Input.dispatchMouseEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "mousePressed");
assert_eq!(sent["params"]["button"], "left");
assert_eq!(sent["params"]["clickCount"], 1);
assert_eq!(sent["params"]["x"], EXPECTED_CLICK_X);
assert_eq!(sent["params"]["y"], EXPECTED_CLICK_Y);
mock.reply(id_press, json!({})).await;
let id_rel = mock.expect_cmd("Input.dispatchMouseEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "mouseReleased");
assert_eq!(sent["params"]["button"], "left");
assert_eq!(sent["params"]["clickCount"], 1);
mock.reply(id_rel, json!({})).await;
let id_poll2 = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id_poll2,
json!({
"result": {
"type": "object",
"value": {
"token": "TOKEN_XYZ",
"bbox": null,
"hasMarkers": true,
}
}
}),
)
.await;
let outcome = fut.await.unwrap().unwrap();
match outcome {
ClearanceOutcome::TokenAcquired(t) => assert_eq!(t, "TOKEN_XYZ"),
other => panic!("expected TokenAcquired, got {other:?}"),
}
conn.shutdown();
}
#[tokio::test]
async fn wait_for_clearance_returns_token_without_click_for_invisible_turnstile() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let fut = tokio::spawn({
let s = sess.clone();
async move {
let b = CloudflareBypass::new(&s).poll_interval(Duration::from_millis(1));
b.wait_for_clearance(Duration::from_secs(5)).await
}
});
let id_poll = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id_poll,
json!({
"result": {
"type": "object",
"value": {
"token": "INVISIBLE_TOKEN",
"bbox": null,
"hasMarkers": true,
}
}
}),
)
.await;
let outcome = fut.await.unwrap().unwrap();
match outcome {
ClearanceOutcome::TokenAcquired(t) => assert_eq!(t, "INVISIBLE_TOKEN"),
other => panic!("expected TokenAcquired, got {other:?}"),
}
conn.shutdown();
}
#[tokio::test]
async fn wait_for_clearance_returns_challenge_gone_when_iframe_disappears_after_click() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let fut = tokio::spawn({
let s = sess.clone();
async move {
let b = CloudflareBypass::new(&s).poll_interval(Duration::from_millis(1));
b.wait_for_clearance(Duration::from_secs(5)).await
}
});
let id_poll1 = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id_poll1,
json!({
"result": {
"type": "object",
"value": {
"token": null,
"bbox": { "x": 10.0, "y": 20.0, "width": 40.0, "height": 40.0 },
"hasMarkers": true,
}
}
}),
)
.await;
for _ in 0..3 {
let id = mock.expect_cmd("Input.dispatchMouseEvent").await;
mock.reply(id, json!({})).await;
}
let id_poll2 = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id_poll2,
json!({
"result": {
"type": "object",
"value": {
"token": null,
"bbox": null,
"hasMarkers": true,
}
}
}),
)
.await;
let outcome = fut.await.unwrap().unwrap();
assert!(matches!(outcome, ClearanceOutcome::ChallengeGone));
conn.shutdown();
}
}