use std::collections::VecDeque; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tungstenite::{connect, Message}; use crate::browser::callback_backend::{ BrowserCallbackError, BrowserCallbackHost as BrowserCallbackExecutor, BrowserCallbackRequest, BrowserCallbackResponse, BrowserCallbackSuccess, }; use crate::pipe::{PipeError, Timing}; const DEFAULT_LOOPBACK_ORIGIN: &str = "http://127.0.0.1:17888"; const DEFAULT_BROWSER_WS_URL: &str = "ws://127.0.0.1:12345"; const HELPER_PAGE_PATH: &str = "/sgclaw/browser-helper.html"; const READY_ENDPOINT_PATH: &str = "/sgclaw/callback/ready"; const EVENTS_ENDPOINT_PATH: &str = "/sgclaw/callback/events"; const COMMANDS_ENDPOINT_PATH: &str = "/sgclaw/callback/commands/next"; const COMMAND_ACK_ENDPOINT_PATH: &str = "/sgclaw/callback/commands/ack"; const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25); const HELPER_POLL_INTERVAL: Duration = Duration::from_millis(50); const HELPER_BOOTSTRAP_ACTION: &str = "sgBrowerserOpenPage"; const NAVIGATE_CALLBACK_NAME: &str = "sgclawOnLoaded"; const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe"; const CLICK_CALLBACK_NAME: &str = "sgclawOnClick"; const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe"; const TYPE_CALLBACK_NAME: &str = "sgclawOnType"; const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText"; const EVAL_CALLBACK_NAME: &str = "sgclawOnEval"; #[derive(Debug)] pub(crate) struct BrowserCallbackHost { helper_url: String, helper_page_html: String, state: Mutex, } #[derive(Debug)] pub(crate) struct LiveBrowserCallbackHost { host: Arc, shutdown: Arc, server_thread: Mutex>>, command_lock: Mutex<()>, result_timeout: Duration, } #[derive(Debug, Default)] struct CallbackHostState { ready: bool, pending_ready_event: Option, pending_results: VecDeque, pending_commands: VecDeque, in_flight_command: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum CallbackEvent { Ready { helper_url: Option }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct CallbackCommand { pub action: String, #[serde(default)] pub args: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct CallbackCommandEnvelope { pub ok: bool, pub command: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct CallbackResult { pub callback: String, pub request_url: String, #[serde(default)] pub target_url: Option, #[serde(default)] pub action: Option, pub payload: Value, } #[derive(Debug, Deserialize)] struct IncomingReadyEvent { #[allow(dead_code)] #[serde(default)] r#type: Option, #[serde(default)] helper_url: Option, } #[derive(Debug, Deserialize)] struct IncomingCallbackEvent { callback: String, request_url: String, #[serde(default)] target_url: Option, #[serde(default)] action: Option, payload: Value, } #[derive(Debug)] struct HttpRequest { method: String, path: String, body: Vec, } #[derive(Debug)] struct ParsedCallbackJsPayload { callback: String, response_text: String, } impl BrowserCallbackHost { pub(crate) fn new() -> Self { Self::with_urls(DEFAULT_LOOPBACK_ORIGIN, DEFAULT_BROWSER_WS_URL) } pub(crate) fn with_urls( loopback_origin: impl AsRef, browser_ws_url: impl AsRef, ) -> Self { let origin = normalize_loopback_origin(loopback_origin.as_ref()); let browser_ws_url = browser_ws_url.as_ref().to_string(); let helper_url = format!("{origin}{HELPER_PAGE_PATH}"); let helper_page_html = build_helper_page_html(&origin, &helper_url, &browser_ws_url); Self { helper_url, helper_page_html, state: Mutex::new(CallbackHostState::default()), } } pub(crate) fn helper_url(&self) -> &str { &self.helper_url } pub(crate) fn helper_page_html(&self) -> &str { &self.helper_page_html } pub(crate) fn is_ready(&self) -> bool { self.state.lock().unwrap().ready } pub(crate) fn mark_ready(&self, helper_url: Option) { let mut state = self.state.lock().unwrap(); if state.ready { return; } state.ready = true; state.pending_ready_event = Some(CallbackEvent::Ready { helper_url }); } pub(crate) fn take_ready_event(&self) -> Option { self.state.lock().unwrap().pending_ready_event.take() } pub(crate) fn push_result(&self, result: CallbackResult) { self.state.lock().unwrap().pending_results.push_back(result); } pub(crate) fn take_result(&self) -> Option { self.state.lock().unwrap().pending_results.pop_front() } pub(crate) fn clear_results(&self) { self.state.lock().unwrap().pending_results.clear(); } pub(crate) fn enqueue_command(&self, command: CallbackCommand) { self.state.lock().unwrap().pending_commands.push_back(command); } pub(crate) fn current_command_envelope(&self) -> CallbackCommandEnvelope { let mut state = self.state.lock().unwrap(); if state.in_flight_command.is_none() { state.in_flight_command = state.pending_commands.pop_front(); } CallbackCommandEnvelope { ok: state.in_flight_command.is_some(), command: state.in_flight_command.clone(), } } pub(crate) fn acknowledge_in_flight_command(&self) -> Option { self.state.lock().unwrap().in_flight_command.take() } /// Clear all pending state so the host can be reused for the next task /// without reopening the helper page. pub(crate) fn reset_pending_state(&self) { let mut state = self.state.lock().unwrap(); state.pending_results.clear(); state.pending_commands.clear(); state.in_flight_command = None; } } impl LiveBrowserCallbackHost { pub(crate) fn start_with_browser_ws_url( browser_ws_url: &str, bootstrap_request_url: &str, ready_timeout: Duration, result_timeout: Duration, ) -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| { PipeError::Protocol(format!("failed to bind callback host listener: {err}")) })?; listener.set_nonblocking(true).map_err(|err| { PipeError::Protocol(format!("failed to configure callback host listener: {err}")) })?; let origin = format!( "http://{}", listener.local_addr().map_err(|err| { PipeError::Protocol(format!( "failed to resolve callback host listener address: {err}" )) })? ); let host = Arc::new(BrowserCallbackHost::with_urls(&origin, browser_ws_url)); let shutdown = Arc::new(AtomicBool::new(false)); let thread_host = host.clone(); let thread_shutdown = shutdown.clone(); let server_thread = thread::spawn(move || serve_loop(listener, thread_host, thread_shutdown)); bootstrap_helper_page(browser_ws_url, bootstrap_request_url, host.helper_url())?; wait_for_helper_ready(host.as_ref(), ready_timeout)?; let live_host = Self { host, shutdown, server_thread: Mutex::new(Some(server_thread)), command_lock: Mutex::new(()), result_timeout, }; Ok(live_host) } pub(crate) fn helper_url(&self) -> &str { self.host.helper_url() } pub(crate) fn reset_pending_state(&self) { self.host.reset_pending_state(); } } fn command_is_fire_and_forget(request: &BrowserCallbackRequest) -> bool { if request.action == "navigate" { return true; } request .command .as_array() .and_then(|items| items.get(1)) .and_then(Value::as_str) .is_some_and(|opcode| { opcode == "sgBroewserSimulateMouse" || opcode == "sgBroewserSimulateKeyborad" }) } impl BrowserCallbackExecutor for LiveBrowserCallbackHost { fn execute(&self, request: BrowserCallbackRequest) -> Result { let _command_guard = self.command_lock.lock().unwrap(); self.host.clear_results(); self.host.enqueue_command(command_from_request(&request.command)?); // Navigate uses sgBrowerserOpenPage which opens a new tab without a JS // callback. Simulated mouse/keyboard follow-up commands also do not emit // a helper-page callback; the caller validates their effect with a later // eval/get-text step. We only wait long enough for the helper page poller // to ACK and forward those commands. let is_fire_and_forget = command_is_fire_and_forget(&request); let timeout = if is_fire_and_forget { Duration::from_millis(1500) } else { self.result_timeout }; let started = Instant::now(); while started.elapsed() < timeout { if let Some(result) = self.host.take_result() { if let Some(response) = normalize_callback_result(&request, result, started.elapsed()) { return Ok(response); } } thread::sleep(COMMAND_POLL_INTERVAL); } if is_fire_and_forget { return Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "loaded": true }), aom_snapshot: vec![], timing: elapsed_timing(started.elapsed()), })); } Err(PipeError::Timeout) } } impl Drop for LiveBrowserCallbackHost { fn drop(&mut self) { self.shutdown.store(true, Ordering::Relaxed); if let Some(handle) = self.server_thread.lock().unwrap().take() { let _ = handle.join(); } } } impl Default for BrowserCallbackHost { fn default() -> Self { Self::new() } } fn normalize_loopback_origin(origin: &str) -> String { origin.trim_end_matches('/').to_string() } fn bootstrap_helper_page(browser_ws_url: &str, request_url: &str, helper_url: &str) -> Result<(), PipeError> { let (mut websocket, _) = connect(browser_ws_url) .map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?; configure_bootstrap_socket(&mut websocket)?; websocket .send(Message::Text( r#"{"type":"register","role":"web"}"#.to_string().into(), )) .map_err(|err| PipeError::Protocol(format!("browser websocket register failed: {err}")))?; let _ = recv_bootstrap_prelude(&mut websocket); let payload = json!([ request_url, HELPER_BOOTSTRAP_ACTION, helper_url, ]) .to_string(); websocket .send(Message::Text(payload.into())) .map_err(|err| PipeError::Protocol(format!("helper bootstrap send failed: {err}")))?; Ok(()) } fn recv_bootstrap_prelude( websocket: &mut tungstenite::WebSocket>, ) -> Result<(), PipeError> { loop { match websocket.read() { Ok(Message::Text(_)) | Ok(Message::Binary(_)) | Ok(Message::Frame(_)) => return Ok(()), Ok(Message::Ping(payload)) => websocket .send(Message::Pong(payload)) .map_err(|err| PipeError::Protocol(format!("browser websocket pong failed: {err}")))?, Ok(Message::Pong(_)) => {} Ok(Message::Close(_)) => return Err(PipeError::PipeClosed), Err(tungstenite::Error::ConnectionClosed) | Err(tungstenite::Error::AlreadyClosed) => { return Err(PipeError::PipeClosed) } Err(tungstenite::Error::Io(err)) if matches!( err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock ) => { return Ok(()); } Err(err) => { return Err(PipeError::Protocol(format!( "browser websocket bootstrap read failed: {err}" ))); } } } } fn configure_bootstrap_socket( websocket: &mut tungstenite::WebSocket>, ) -> Result<(), PipeError> { match websocket.get_mut() { tungstenite::stream::MaybeTlsStream::Plain(stream) => { stream.set_read_timeout(Some(Duration::from_secs(1)))?; stream.set_write_timeout(Some(Duration::from_secs(1)))?; Ok(()) } _ => Ok(()), } } fn wait_for_helper_ready(host: &BrowserCallbackHost, ready_timeout: Duration) -> Result<(), PipeError> { let started = Instant::now(); while started.elapsed() < ready_timeout { if host.is_ready() { return Ok(()); } thread::sleep(HELPER_POLL_INTERVAL); } Err(PipeError::Timeout) } fn serve_loop(listener: TcpListener, host: Arc, shutdown: Arc) { while !shutdown.load(Ordering::Relaxed) { match listener.accept() { Ok((mut stream, _)) => { let _ = handle_request(&mut stream, host.as_ref()); } Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(COMMAND_POLL_INTERVAL); } Err(_) => { thread::sleep(COMMAND_POLL_INTERVAL); } } } } fn handle_request(stream: &mut TcpStream, host: &BrowserCallbackHost) -> Result<(), PipeError> { let request = read_http_request(stream)?; // Handle CORS preflight requests from cross-origin pages (e.g. JS injected // into zhihu.com that POSTs results back to this loopback server). if request.method == "OPTIONS" { return write_cors_preflight(stream); } match (request.method.as_str(), request.path.as_str()) { ("GET", HELPER_PAGE_PATH) => write_http_response( stream, 200, "text/html; charset=utf-8", host.helper_page_html().as_bytes(), ), ("POST", READY_ENDPOINT_PATH) => { let payload: IncomingReadyEvent = serde_json::from_slice(&request.body).map_err(|err| { PipeError::Protocol(format!("invalid callback host ready payload: {err}")) })?; host.mark_ready(payload.helper_url); write_json_response(stream, &json!({ "ok": true })) } ("POST", EVENTS_ENDPOINT_PATH) => { let payload: IncomingCallbackEvent = serde_json::from_slice(&request.body).map_err(|err| { PipeError::Protocol(format!("invalid callback host event payload: {err}")) })?; host.push_result(CallbackResult { callback: payload.callback, request_url: payload.request_url, target_url: payload.target_url, action: payload.action, payload: payload.payload, }); write_json_response(stream, &json!({ "ok": true })) } ("GET", COMMANDS_ENDPOINT_PATH) => { let envelope = host.current_command_envelope(); write_json_response(stream, &envelope) } ("POST", COMMAND_ACK_ENDPOINT_PATH) => { host.acknowledge_in_flight_command(); write_json_response(stream, &json!({ "ok": true })) } _ => write_http_response(stream, 404, "text/plain; charset=utf-8", b"not found"), } } fn read_http_request(stream: &mut TcpStream) -> Result { let mut buffer = Vec::new(); let mut headers_end = None; while headers_end.is_none() { let mut chunk = [0_u8; 1024]; let bytes = stream.read(&mut chunk).map_err(|err| { PipeError::Protocol(format!("failed to read callback host request headers: {err}")) })?; if bytes == 0 { return Err(PipeError::PipeClosed); } buffer.extend_from_slice(&chunk[..bytes]); headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n"); } let headers_end = headers_end.expect("headers end must exist") + 4; let headers = String::from_utf8(buffer[..headers_end].to_vec()).map_err(|err| { PipeError::Protocol(format!("invalid callback host request headers: {err}")) })?; let mut lines = headers.lines(); let request_line = lines .next() .ok_or_else(|| PipeError::Protocol("missing callback host request line".to_string()))?; let mut request_parts = request_line.split_whitespace(); let method = request_parts .next() .ok_or_else(|| PipeError::Protocol("missing callback host request method".to_string()))? .to_string(); let path = request_parts .next() .ok_or_else(|| PipeError::Protocol("missing callback host request path".to_string()))? .to_string(); let content_length = lines .find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse::().ok()) .flatten() }) .unwrap_or(0); while buffer.len() < headers_end + content_length { let mut chunk = vec![0_u8; content_length.max(1024)]; let bytes = stream.read(&mut chunk).map_err(|err| { PipeError::Protocol(format!("failed to read callback host request body: {err}")) })?; if bytes == 0 { return Err(PipeError::PipeClosed); } buffer.extend_from_slice(&chunk[..bytes]); } Ok(HttpRequest { method, path, body: buffer[headers_end..headers_end + content_length].to_vec(), }) } fn write_json_response(stream: &mut TcpStream, payload: &impl Serialize) -> Result<(), PipeError> { let body = serde_json::to_vec(payload).map_err(|err| { PipeError::Protocol(format!("failed to serialize callback host response: {err}")) })?; write_http_response(stream, 200, "application/json", &body) } fn write_http_response( stream: &mut TcpStream, status_code: u16, content_type: &str, body: &[u8], ) -> Result<(), PipeError> { let status_text = match status_code { 200 => "OK", 404 => "Not Found", _ => "OK", }; let headers = format!( "HTTP/1.1 {status_code} {status_text}\r\n\ Content-Type: {content_type}\r\n\ Content-Length: {}\r\n\ Access-Control-Allow-Origin: *\r\n\ Connection: close\r\n\r\n", body.len() ); stream .write_all(headers.as_bytes()) .and_then(|_| stream.write_all(body)) .and_then(|_| stream.flush()) .map_err(|err| PipeError::Protocol(format!("failed to write callback host response: {err}"))) } fn write_cors_preflight(stream: &mut TcpStream) -> Result<(), PipeError> { let headers = "HTTP/1.1 204 No Content\r\n\ Access-Control-Allow-Origin: *\r\n\ Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\ Access-Control-Allow-Headers: content-type\r\n\ Access-Control-Max-Age: 86400\r\n\ Content-Length: 0\r\n\ Connection: close\r\n\r\n"; stream .write_all(headers.as_bytes()) .and_then(|_| stream.flush()) .map_err(|err| PipeError::Protocol(format!("failed to write CORS preflight response: {err}"))) } fn command_from_request(command: &Value) -> Result { let values = command.as_array().ok_or_else(|| { PipeError::Protocol(format!("callback host command must be an array, got {command}")) })?; if values.len() < 2 { return Err(PipeError::Protocol(format!( "callback host command must include request_url and action, got {command}" ))); } let action = values[1] .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { PipeError::Protocol(format!("callback host command action is invalid: {command}")) })? .to_string(); Ok(CallbackCommand { action, args: values[2..].to_vec(), }) } fn normalize_callback_result( request: &BrowserCallbackRequest, result: CallbackResult, elapsed: Duration, ) -> Option { match request.action.as_str() { "navigate" if result.callback == NAVIGATE_CALLBACK_NAME => { Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "loaded": true, "target_url": result.target_url, }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } "click" if result.callback == CLICK_PROBE_CALLBACK_NAME => { let x = result.payload.get("x").and_then(Value::as_f64)?; let y = result.payload.get("y").and_then(Value::as_f64)?; Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "probe": { "x": x, "y": y }, "callback": CLICK_CALLBACK_NAME, }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } "type" if result.callback == TYPE_PROBE_CALLBACK_NAME => { let x = result.payload.get("x").and_then(Value::as_f64)?; let y = result.payload.get("y").and_then(Value::as_f64)?; let text = result.payload.get("text").and_then(Value::as_str).unwrap_or_default(); Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "probe": { "x": x, "y": y, "text": text }, "callback": TYPE_CALLBACK_NAME, }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } // Path A: The browser's native callBackJsToCpp routes the callback to // the helper page and calls sgclawOnGetText / sgclawOnEval directly. // The helper page POSTs to the events endpoint with the callback name // and payload (e.g. { text: "..." } or { value: "..." }). "getText" if result.callback == GET_TEXT_CALLBACK_NAME => { let text = result.payload.get("text").and_then(Value::as_str)?; Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "text": text }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } "eval" if result.callback == EVAL_CALLBACK_NAME => { let value = result.payload.get("value").and_then(Value::as_str)?; Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "text": value }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } // Path B: The browser's native callBackJsToCpp calls the helper page's // callBackJsToCpp function with the @_@ delimited string. The helper // page parses it and POSTs to the events endpoint with callback: // "callBackJsToCpp" and payload: { raw: "..." }. "getText" | "eval" | "click" | "type" if result.callback == "callBackJsToCpp" => { let raw = result.payload.get("raw").and_then(Value::as_str)?; let parsed = match parse_callback_js_payload(raw) { Ok(parsed) => parsed, Err(message) => { return Some(BrowserCallbackResponse::Error(BrowserCallbackError { message, details: result.payload, })) } }; let expected_callback = expected_callback_name(&request.action).ok()?; if parsed.callback != expected_callback { return None; } match request.action.as_str() { "click" => { let probe: Value = serde_json::from_str(&parsed.response_text).ok()?; let x = probe.get("x").and_then(Value::as_f64)?; let y = probe.get("y").and_then(Value::as_f64)?; Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "probe": { "x": x, "y": y }, "callback": CLICK_CALLBACK_NAME, }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } "type" => { let probe: Value = serde_json::from_str(&parsed.response_text).ok()?; let x = probe.get("x").and_then(Value::as_f64)?; let y = probe.get("y").and_then(Value::as_f64)?; let text = probe.get("text").and_then(Value::as_str).unwrap_or_default(); Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "probe": { "x": x, "y": y, "text": text }, "callback": TYPE_CALLBACK_NAME, }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } _ => { // getText / eval — return raw text Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess { success: true, data: json!({ "text": parsed.response_text }), aom_snapshot: vec![], timing: elapsed_timing(elapsed), })) } } } _ => None, } } fn parse_callback_js_payload(raw: &str) -> Result { let mut parts = raw.splitn(5, "@_@"); let _source_url = parts .next() .ok_or_else(|| "missing callback source_url segment".to_string())?; let _target_url = parts .next() .ok_or_else(|| "missing callback target_url segment".to_string())?; let callback = parts .next() .ok_or_else(|| "missing callback name segment".to_string())?; let _action_url = parts .next() .ok_or_else(|| "missing callback action_url segment".to_string())?; let response_text = parts .next() .ok_or_else(|| "missing callback response_text segment".to_string())?; Ok(ParsedCallbackJsPayload { callback: callback.to_string(), response_text: response_text.to_string(), }) } fn expected_callback_name(action: &str) -> Result<&'static str, PipeError> { match action { "navigate" => Ok(NAVIGATE_CALLBACK_NAME), "click" => Ok(CLICK_PROBE_CALLBACK_NAME), "type" => Ok(TYPE_PROBE_CALLBACK_NAME), "getText" => Ok(GET_TEXT_CALLBACK_NAME), "eval" => Ok(EVAL_CALLBACK_NAME), other => Err(PipeError::Protocol(format!( "unsupported callback host action result normalization: {other}" ))), } } fn elapsed_timing(elapsed: Duration) -> Timing { Timing { queue_ms: 0, exec_ms: elapsed.as_millis() as u64, } } fn build_helper_page_html(loopback_origin: &str, helper_url: &str, browser_ws_url: &str) -> String { format!( r#" sgClaw · Runtime Console

sgClaw · Runtime Console

Browser Automation Agent

Connecting…
Commands
0
Callbacks
0
Uptime
0s
Current
Initializing…
sgClaw v0.1 · Browser Callback Host
"# ) } #[cfg(test)] mod tests { use super::{ BrowserCallbackHost, CallbackCommand, CallbackCommandEnvelope, CallbackEvent, CallbackResult, LiveBrowserCallbackHost, }; use serde_json::json; use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; use tungstenite::{accept, Message}; fn start_fake_browser_status_server() -> (String, Arc>>, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let frames = Arc::new(Mutex::new(Vec::new())); let frames_for_thread = Arc::clone(&frames); // Use a blocking accept so the thread waits for a connection reliably. // On Windows, non-blocking listeners can cause the accepted stream to // inherit non-blocking mode, making tungstenite reads return WouldBlock // immediately. let handle = thread::spawn(move || { let (stream, _) = listener.accept().expect("fake browser ws server accept"); stream.set_nonblocking(false).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); stream .set_write_timeout(Some(Duration::from_secs(3))) .unwrap(); let mut socket = accept(stream).unwrap(); // Send welcome banner proactively (like the real browser does). let _ = socket.send(Message::Text( r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"# .to_string() .into(), )); loop { match socket.read() { Ok(Message::Text(text)) => { frames_for_thread.lock().unwrap().push(text.to_string()); } Ok(Message::Ping(payload)) => { let _ = socket.send(Message::Pong(payload)); } Ok(Message::Close(_)) => break, Ok(_) => {} Err(tungstenite::Error::ConnectionClosed) | Err(tungstenite::Error::AlreadyClosed) => break, Err(tungstenite::Error::Io(err)) if matches!( err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) => { break; } Err(_) => { break; } } } }); (format!("ws://{address}"), frames, handle) } #[test] fn live_callback_host_sends_bootstrap_open_page_command() { let (ws_url, frames, handle) = start_fake_browser_status_server(); // The helper page will not actually load in a test environment, so // start_with_browser_ws_url returns Err(Timeout) from wait_for_helper_ready. // We still verify that the bootstrap command was sent correctly. let result = LiveBrowserCallbackHost::start_with_browser_ws_url( &ws_url, "https://www.zhihu.com", Duration::from_millis(100), Duration::from_millis(50), ); assert!(result.is_err(), "expected timeout because no real helper page loads"); drop(result); handle.join().unwrap(); let sent = frames.lock().unwrap().clone(); assert!( sent.iter().any(|frame| frame.contains("sgBrowerserOpenPage")), "bootstrap should send sgBrowerserOpenPage to the browser WS; sent frames: {sent:?}" ); assert!( sent.iter().any(|frame| frame.contains("/sgclaw/browser-helper.html")), "bootstrap should include the helper page URL; sent frames: {sent:?}" ); assert!( sent.iter().any(|frame| frame.contains("https://www.zhihu.com")), "bootstrap requestUrl should be the provided page URL; sent frames: {sent:?}" ); } #[test] fn live_callback_host_treats_simulated_mouse_command_as_fire_and_forget() { use crate::browser::callback_backend::{ BrowserCallbackHost as BrowserCallbackExecutor, BrowserCallbackRequest, }; use std::sync::atomic::AtomicBool; let host = LiveBrowserCallbackHost { host: Arc::new(BrowserCallbackHost::new()), shutdown: Arc::new(AtomicBool::new(false)), server_thread: Mutex::new(None), command_lock: Mutex::new(()), result_timeout: Duration::from_millis(10), }; let response = host.execute(BrowserCallbackRequest { seq: 1, request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(), expected_domain: "zhuanlan.zhihu.com".to_string(), action: "click".to_string(), command: json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBroewserSimulateMouse", 320.5, 240.25, "left", "", "" ]), }); assert!(response.is_ok(), "simulated mouse follow-up should not wait for a callback"); } #[test] fn callback_host_exposes_loopback_helper_url_and_release_helper_html() { let host = BrowserCallbackHost::new(); assert_eq!( host.helper_url(), "http://127.0.0.1:17888/sgclaw/browser-helper.html" ); let html = host.helper_page_html(); assert!(html.contains("ws://127.0.0.1:12345")); assert!(html.contains(r#"JSON.stringify({ type: 'register', role: 'web' })"#)); assert!(html.contains("sgclawReady")); assert!(html.contains("sgclawOnLoaded")); assert!(html.contains("sgclawOnClickProbe")); assert!(html.contains("sgclawOnClick")); assert!(html.contains("sgclawOnTypeProbe")); assert!(html.contains("sgclawOnType")); assert!(html.contains("sgclawOnGetText")); assert!(html.contains("sgclawOnEval")); assert!(html.contains("/sgclaw/callback/ready")); assert!(html.contains("/sgclaw/callback/events")); assert!(html.contains("/sgclaw/callback/commands/next")); assert!(html.contains("/sgclaw/callback/commands/ack")); } #[test] fn callback_host_tracks_ready_state_only_once() { let host = BrowserCallbackHost::new(); assert!(!host.is_ready()); assert!(host.take_ready_event().is_none()); host.mark_ready(Some("http://127.0.0.1/helper.html".to_string())); assert!(host.is_ready()); assert_eq!( host.take_ready_event(), Some(CallbackEvent::Ready { helper_url: Some("http://127.0.0.1/helper.html".to_string()), }) ); host.mark_ready(Some("http://127.0.0.1/ignored.html".to_string())); assert!(host.take_ready_event().is_none()); } #[test] fn callback_host_queues_structured_callback_results_for_later_consumption() { let host = BrowserCallbackHost::new(); host.push_result(CallbackResult { callback: "sgclawOnGetText".to_string(), request_url: "http://127.0.0.1/helper.html".to_string(), target_url: Some("https://example.com/page".to_string()), action: Some("sgBrowserExcuteJsFun".to_string()), payload: json!({ "text": "hello", "meta": { "source": "page" } }), }); host.push_result(CallbackResult { callback: "sgclawOnEval".to_string(), request_url: "http://127.0.0.1/helper.html".to_string(), target_url: None, action: Some("callBackJsToCpp".to_string()), payload: json!({ "value": 42 }), }); assert_eq!( host.take_result(), Some(CallbackResult { callback: "sgclawOnGetText".to_string(), request_url: "http://127.0.0.1/helper.html".to_string(), target_url: Some("https://example.com/page".to_string()), action: Some("sgBrowserExcuteJsFun".to_string()), payload: json!({ "text": "hello", "meta": { "source": "page" } }), }) ); assert_eq!( host.take_result(), Some(CallbackResult { callback: "sgclawOnEval".to_string(), request_url: "http://127.0.0.1/helper.html".to_string(), target_url: None, action: Some("callBackJsToCpp".to_string()), payload: json!({ "value": 42 }), }) ); assert!(host.take_result().is_none()); } #[test] fn callback_host_repeats_inflight_command_until_acknowledged() { let host = BrowserCallbackHost::new(); host.enqueue_command(CallbackCommand { action: "sgBrowserSetTheme".to_string(), args: vec![json!("1")], }); host.enqueue_command(CallbackCommand { action: "sgBrowerserGetUrls".to_string(), args: vec![json!("showUrls")], }); assert_eq!( host.current_command_envelope(), CallbackCommandEnvelope { ok: true, command: Some(CallbackCommand { action: "sgBrowserSetTheme".to_string(), args: vec![json!("1")], }), } ); assert_eq!( host.current_command_envelope(), CallbackCommandEnvelope { ok: true, command: Some(CallbackCommand { action: "sgBrowserSetTheme".to_string(), args: vec![json!("1")], }), } ); assert_eq!( host.acknowledge_in_flight_command(), Some(CallbackCommand { action: "sgBrowserSetTheme".to_string(), args: vec![json!("1")], }) ); assert_eq!( host.current_command_envelope(), CallbackCommandEnvelope { ok: true, command: Some(CallbackCommand { action: "sgBrowerserGetUrls".to_string(), args: vec![json!("showUrls")], }), } ); assert_eq!( host.acknowledge_in_flight_command(), Some(CallbackCommand { action: "sgBrowerserGetUrls".to_string(), args: vec![json!("showUrls")], }) ); assert_eq!( host.current_command_envelope(), CallbackCommandEnvelope { ok: false, command: None, } ); assert!(host.acknowledge_in_flight_command().is_none()); } // ── Path B callBackJsToCpp normalization tests ──────────────────── use super::normalize_callback_result; use crate::browser::callback_backend::BrowserCallbackRequest; fn make_request(action: &str) -> BrowserCallbackRequest { BrowserCallbackRequest { seq: 1, request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(), expected_domain: "zhuanlan.zhihu.com".to_string(), action: action.to_string(), command: json!([ "http://127.0.0.1:17888/sgclaw/browser-helper.html", "sgBrowserExcuteJsCodeByDomain", "zhuanlan.zhihu.com", "(function(){ /* probe */ })()" ]), } } fn make_callback_js_to_cpp_result(raw: &str) -> CallbackResult { CallbackResult { callback: "callBackJsToCpp".to_string(), request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(), target_url: Some("https://zhuanlan.zhihu.com/write".to_string()), action: Some("sgBrowserExcuteJsCodeByDomain".to_string()), payload: json!({ "raw": raw }), } } #[test] fn normalize_callback_result_path_b_click_probe() { let request = make_request("click"); let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnClickProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":320.5,\"y\":240.25}"; let result = make_callback_js_to_cpp_result(raw); let response = normalize_callback_result(&request, result, Duration::from_millis(10)); assert!(response.is_some(), "Path B click should produce a response"); match response.unwrap() { super::super::callback_backend::BrowserCallbackResponse::Success(s) => { let probe = s.data.get("probe").expect("should have probe"); assert_eq!(probe.get("x").unwrap().as_f64().unwrap(), 320.5); assert_eq!(probe.get("y").unwrap().as_f64().unwrap(), 240.25); assert_eq!( s.data.get("callback").unwrap().as_str().unwrap(), "sgclawOnClick" ); } other => panic!("expected Success, got {other:?}"), } } #[test] fn normalize_callback_result_path_b_type_probe() { let request = make_request("type"); let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnTypeProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":100,\"y\":200,\"text\":\"hello\"}"; let result = make_callback_js_to_cpp_result(raw); let response = normalize_callback_result(&request, result, Duration::from_millis(10)); assert!(response.is_some(), "Path B type should produce a response"); match response.unwrap() { super::super::callback_backend::BrowserCallbackResponse::Success(s) => { let probe = s.data.get("probe").expect("should have probe"); assert_eq!(probe.get("x").unwrap().as_f64().unwrap(), 100.0); assert_eq!(probe.get("y").unwrap().as_f64().unwrap(), 200.0); assert_eq!(probe.get("text").unwrap().as_str().unwrap(), "hello"); assert_eq!( s.data.get("callback").unwrap().as_str().unwrap(), "sgclawOnType" ); } other => panic!("expected Success, got {other:?}"), } } #[test] fn normalize_callback_result_path_b_click_wrong_callback_returns_none() { let request = make_request("click"); // callback name is sgclawOnTypeProbe (wrong for click action) let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnTypeProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":1,\"y\":2}"; let result = make_callback_js_to_cpp_result(raw); let response = normalize_callback_result(&request, result, Duration::from_millis(10)); assert!(response.is_none(), "mismatched callback name should return None"); } #[test] fn normalize_callback_result_path_b_eval_still_works() { let request = make_request("eval"); let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnEval@_@sgBrowserExcuteJsCodeByDomain@_@{\"status\":\"ok\"}"; let result = make_callback_js_to_cpp_result(raw); let response = normalize_callback_result(&request, result, Duration::from_millis(10)); assert!(response.is_some(), "Path B eval should still work"); match response.unwrap() { super::super::callback_backend::BrowserCallbackResponse::Success(s) => { let text = s.data.get("text").unwrap().as_str().unwrap(); assert_eq!(text, r#"{"status":"ok"}"#); } other => panic!("expected Success, got {other:?}"), } } }