Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1365 lines
53 KiB
Rust
1365 lines
53 KiB
Rust
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<CallbackHostState>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct LiveBrowserCallbackHost {
|
|
host: Arc<BrowserCallbackHost>,
|
|
shutdown: Arc<AtomicBool>,
|
|
server_thread: Mutex<Option<JoinHandle<()>>>,
|
|
command_lock: Mutex<()>,
|
|
result_timeout: Duration,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct CallbackHostState {
|
|
ready: bool,
|
|
pending_ready_event: Option<CallbackEvent>,
|
|
pending_results: VecDeque<CallbackResult>,
|
|
pending_commands: VecDeque<CallbackCommand>,
|
|
in_flight_command: Option<CallbackCommand>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub(crate) enum CallbackEvent {
|
|
Ready { helper_url: Option<String> },
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub(crate) struct CallbackCommand {
|
|
pub action: String,
|
|
#[serde(default)]
|
|
pub args: Vec<Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub(crate) struct CallbackCommandEnvelope {
|
|
pub ok: bool,
|
|
pub command: Option<CallbackCommand>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub(crate) struct CallbackResult {
|
|
pub callback: String,
|
|
pub request_url: String,
|
|
#[serde(default)]
|
|
pub target_url: Option<String>,
|
|
#[serde(default)]
|
|
pub action: Option<String>,
|
|
pub payload: Value,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct IncomingReadyEvent {
|
|
#[allow(dead_code)]
|
|
#[serde(default)]
|
|
r#type: Option<String>,
|
|
#[serde(default)]
|
|
helper_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct IncomingCallbackEvent {
|
|
callback: String,
|
|
request_url: String,
|
|
#[serde(default)]
|
|
target_url: Option<String>,
|
|
#[serde(default)]
|
|
action: Option<String>,
|
|
payload: Value,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct HttpRequest {
|
|
method: String,
|
|
path: String,
|
|
body: Vec<u8>,
|
|
}
|
|
|
|
#[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<str>,
|
|
browser_ws_url: impl AsRef<str>,
|
|
) -> 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<String>) {
|
|
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<CallbackEvent> {
|
|
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<CallbackResult> {
|
|
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<CallbackCommand> {
|
|
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<Self, PipeError> {
|
|
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<BrowserCallbackResponse, PipeError> {
|
|
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<tungstenite::stream::MaybeTlsStream<TcpStream>>,
|
|
) -> 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<tungstenite::stream::MaybeTlsStream<TcpStream>>,
|
|
) -> 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<BrowserCallbackHost>, shutdown: Arc<AtomicBool>) {
|
|
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<HttpRequest, PipeError> {
|
|
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::<usize>().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<CallbackCommand, PipeError> {
|
|
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<BrowserCallbackResponse> {
|
|
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<ParsedCallbackJsPayload, String> {
|
|
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#"<!doctype html>
|
|
<html><head><meta charset="utf-8"/><title>sgClaw · Runtime Console</title>
|
|
<style>
|
|
*{{margin:0;padding:0;box-sizing:border-box}}
|
|
body{{background:#0b1120;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;height:100vh;overflow:hidden;display:flex;flex-direction:column}}
|
|
.hd{{padding:20px 24px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:16px}}
|
|
.logo{{width:36px;height:36px;background:linear-gradient(135deg,#3b82f6,#06b6d4);border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;color:#fff;flex-shrink:0}}
|
|
.ti{{flex:1}}.ti h1{{font-size:16px;font-weight:600;color:#f1f5f9}}.ti p{{font-size:12px;color:#64748b;margin-top:2px}}
|
|
.sb{{display:flex;align-items:center;gap:6px;font-size:12px;color:#94a3b8}}
|
|
.sd{{width:8px;height:8px;border-radius:50%;background:#475569}}.sd.on{{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5);animation:pulse 2s ease-in-out infinite}}
|
|
@keyframes pulse{{0%,100%{{opacity:1}}50%{{opacity:.5}}}}
|
|
.stats{{padding:14px 24px;display:flex;gap:32px;border-bottom:1px solid #1e293b}}.st{{display:flex;flex-direction:column;gap:2px}}.st .l{{font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.5px}}.st .v{{font-size:20px;font-weight:600;color:#e2e8f0;font-variant-numeric:tabular-nums}}
|
|
.tb{{padding:12px 24px;background:#111827;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:12px}}.sp{{width:16px;height:16px;border:2px solid #1e293b;border-top-color:#3b82f6;border-radius:50%;animation:spin 1s linear infinite}}@keyframes spin{{to{{transform:rotate(360deg)}}}}.tb .tl{{font-size:12px;color:#64748b}}.tb .tt{{font-size:14px;color:#e2e8f0}}
|
|
.log{{flex:1;padding:16px 24px;overflow-y:auto;font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:13px;line-height:1.7}}.le{{color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}.le .t{{color:#475569}}.le .a{{color:#3b82f6}}.le .u{{color:#06b6d4}}.le .ok{{color:#22c55e}}.le .er{{color:#ef4444}}
|
|
.ft{{padding:12px 24px;border-top:1px solid #1e293b;font-size:11px;color:#475569;display:flex;justify-content:space-between}}
|
|
::-webkit-scrollbar{{width:6px}}::-webkit-scrollbar-track{{background:transparent}}::-webkit-scrollbar-thumb{{background:#1e293b;border-radius:3px}}
|
|
</style></head>
|
|
<body>
|
|
<div class="hd"><div class="logo">sg</div><div class="ti"><h1>sgClaw · Runtime Console</h1><p>Browser Automation Agent</p></div><div class="sb"><div class="sd" id="sd"></div><span id="stx">Connecting…</span></div></div>
|
|
<div class="stats"><div class="st"><div class="l">Commands</div><div class="v" id="nc">0</div></div><div class="st"><div class="l">Callbacks</div><div class="v" id="nb">0</div></div><div class="st"><div class="l">Uptime</div><div class="v" id="ut">0s</div></div></div>
|
|
<div class="tb" id="tb"><div class="sp"></div><div><div class="tl">Current</div><div class="tt" id="tt">Initializing…</div></div></div>
|
|
<div class="log" id="lg"></div>
|
|
<div class="ft"><span>sgClaw v0.1 · Browser Callback Host</span><span id="wi"></span></div>
|
|
<script>
|
|
const SGCLAW_LOOPBACK_ORIGIN = {loopback_origin:?};
|
|
const SGCLAW_HELPER_URL = {helper_url:?};
|
|
const SGCLAW_BROWSER_WS_URL = {browser_ws_url:?};
|
|
const SGCLAW_READY_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{READY_ENDPOINT_PATH}`;
|
|
const SGCLAW_EVENTS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{EVENTS_ENDPOINT_PATH}`;
|
|
const SGCLAW_COMMANDS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMANDS_ENDPOINT_PATH}`;
|
|
const SGCLAW_COMMAND_ACK_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMAND_ACK_ENDPOINT_PATH}`;
|
|
|
|
var _nc=0,_nb=0,_t0=Date.now(),_lastCmd=0,_idle=true;
|
|
function _log(msg){{var d=new Date();var ts=[d.getHours(),d.getMinutes(),d.getSeconds()].map(function(v){{return v<10?'0'+v:v;}}).join(':');var el=document.getElementById('lg');var r=document.createElement('div');r.className='le';r.innerHTML='<span class="t">'+ts+'</span> '+msg;el.appendChild(r);el.scrollTop=el.scrollHeight;if(el.children.length>200)el.removeChild(el.children[0]);}}
|
|
function _task(t){{document.getElementById('tt').textContent=t;}}
|
|
function _setIdle(v){{if(_idle===v)return;_idle=v;var sp=document.querySelector('.sp');var tl=document.querySelector('.tb .tl');var tt=document.getElementById('tt');if(v){{sp.style.borderTopColor='#22c55e';sp.style.animation='none';tl.textContent='Status';tt.textContent='Ready \u2014 waiting for commands';tt.style.color='#22c55e';}}else{{sp.style.borderTopColor='';sp.style.animation='';tl.textContent='Current';tt.style.color='';}}}}
|
|
function _stat(){{document.getElementById('nc').textContent=_nc;document.getElementById('nb').textContent=_nb;var s=Math.floor((Date.now()-_t0)/1000);var m=Math.floor(s/60);s=s%60;document.getElementById('ut').textContent=m>0?m+'m '+s+'s':s+'s';if(!_idle&&_lastCmd>0&&Date.now()-_lastCmd>3000)_setIdle(true);}}
|
|
setInterval(_stat,1000);
|
|
|
|
async function sgclawPostJson(url, body) {{
|
|
await fetch(url, {{
|
|
method: 'POST',
|
|
headers: {{ 'content-type': 'application/json' }},
|
|
body: JSON.stringify(body)
|
|
}});
|
|
}}
|
|
|
|
async function sgclawReady() {{
|
|
await sgclawPostJson(SGCLAW_READY_ENDPOINT, {{
|
|
type: 'ready',
|
|
helper_url: window.location.href || SGCLAW_HELPER_URL
|
|
}});
|
|
}}
|
|
|
|
async function sgclawEmitCallback(callback, payload, extra) {{
|
|
_nb++;_lastCmd=Date.now();_log('<span class="ok">\u2190</span> callback <span class="a">'+callback+'</span>');
|
|
await sgclawPostJson(SGCLAW_EVENTS_ENDPOINT, Object.assign({{
|
|
type: 'callback',
|
|
callback,
|
|
request_url: window.location.href || SGCLAW_HELPER_URL,
|
|
payload
|
|
}}, extra || {{}}));
|
|
}}
|
|
|
|
function sgclawOnLoaded(targetUrl) {{
|
|
_task('Page loaded');
|
|
return sgclawEmitCallback('sgclawOnLoaded', {{ loaded: true }}, {{ target_url: targetUrl || null }});
|
|
}}
|
|
|
|
function sgclawOnClickProbe(x, y) {{
|
|
return sgclawEmitCallback('sgclawOnClickProbe', {{ x: Number(x) || 0, y: Number(y) || 0 }});
|
|
}}
|
|
|
|
function sgclawOnClick() {{
|
|
return sgclawEmitCallback('sgclawOnClick', {{ clicked: true }});
|
|
}}
|
|
|
|
function sgclawOnTypeProbe(x, y, text) {{
|
|
return sgclawEmitCallback('sgclawOnTypeProbe', {{ x: Number(x) || 0, y: Number(y) || 0, text: text ?? '' }});
|
|
}}
|
|
|
|
function sgclawOnType() {{
|
|
return sgclawEmitCallback('sgclawOnType', {{ typed: true }});
|
|
}}
|
|
|
|
function sgclawOnGetText(text, targetUrl) {{
|
|
return sgclawEmitCallback('sgclawOnGetText', {{ text: text ?? null }}, {{ target_url: targetUrl || null }});
|
|
}}
|
|
|
|
function sgclawOnEval(value, targetUrl) {{
|
|
_task('Eval complete');
|
|
return sgclawEmitCallback('sgclawOnEval', {{ value: value ?? null }}, {{ target_url: targetUrl || null }});
|
|
}}
|
|
|
|
function callBackJsToCpp(param) {{
|
|
const parts = String(param || '').split('@_@');
|
|
return sgclawEmitCallback('callBackJsToCpp', {{ raw: String(param || '') }}, {{
|
|
target_url: parts[1] || null,
|
|
action: parts[3] || null
|
|
}});
|
|
}}
|
|
|
|
window.sgclawOnLoaded = sgclawOnLoaded;
|
|
window.sgclawOnClickProbe = sgclawOnClickProbe;
|
|
window.sgclawOnClick = sgclawOnClick;
|
|
window.sgclawOnTypeProbe = sgclawOnTypeProbe;
|
|
window.sgclawOnType = sgclawOnType;
|
|
window.sgclawOnGetText = sgclawOnGetText;
|
|
window.sgclawOnEval = sgclawOnEval;
|
|
window.callBackJsToCpp = callBackJsToCpp;
|
|
|
|
document.getElementById('wi').textContent = SGCLAW_BROWSER_WS_URL;
|
|
_log('Connecting to browser WebSocket\u2026');
|
|
|
|
const sgclawSocket = new WebSocket(SGCLAW_BROWSER_WS_URL);
|
|
sgclawSocket.addEventListener('open', async () => {{
|
|
document.getElementById('sd').classList.add('on');
|
|
document.getElementById('stx').textContent = 'Connected';
|
|
_log('<span class="ok">\u2713</span> WebSocket connected');
|
|
_task('Connected to browser');
|
|
sgclawSocket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
|
|
await sgclawReady();
|
|
_log('<span class="ok">\u2713</span> Ready signal sent');
|
|
_task('Ready \u2014 waiting for commands');
|
|
}});
|
|
|
|
sgclawSocket.addEventListener('close', () => {{
|
|
document.getElementById('sd').classList.remove('on');
|
|
document.getElementById('stx').textContent = 'Disconnected';
|
|
_log('<span class="er">\u2717</span> WebSocket disconnected');
|
|
_task('Disconnected');
|
|
}});
|
|
|
|
sgclawSocket.addEventListener('message', (event) => {{
|
|
console.debug('sgclaw helper received browser frame', event.data);
|
|
try {{
|
|
var data = String(event.data || '');
|
|
if (data.indexOf('@_@') !== -1) {{
|
|
sgclawEmitCallback('callBackJsToCpp', {{ raw: data }});
|
|
}}
|
|
}} catch (_e) {{}}
|
|
}});
|
|
|
|
async function sgclawPollCommands() {{
|
|
try {{
|
|
const response = await fetch(SGCLAW_COMMANDS_ENDPOINT, {{ cache: 'no-store' }});
|
|
if (!response.ok) {{
|
|
return;
|
|
}}
|
|
const envelope = await response.json();
|
|
const command = envelope && envelope.command;
|
|
if (!command || !command.action) {{
|
|
return;
|
|
}}
|
|
_nc++;
|
|
const args = Array.isArray(command.args) ? command.args : [];
|
|
_lastCmd=Date.now();_setIdle(false);
|
|
_log('<span class="a">\u2192</span> execute <span class="a">'+command.action+'</span>'+(args.length>1?' <span class="u">'+String(args[1]||'').substring(0,50)+'</span>':''));
|
|
_task('Executing: '+command.action);
|
|
if (sgclawSocket.readyState !== WebSocket.OPEN) {{
|
|
return;
|
|
}}
|
|
sgclawSocket.send(JSON.stringify([window.location.href || SGCLAW_HELPER_URL, command.action, ...args]));
|
|
await sgclawPostJson(SGCLAW_COMMAND_ACK_ENDPOINT, {{ type: 'command_ack' }});
|
|
}} catch (_error) {{
|
|
}}
|
|
}}
|
|
|
|
setInterval(sgclawPollCommands, 250);
|
|
_log('sgClaw Runtime Console initialized');
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"#
|
|
)
|
|
}
|
|
|
|
#[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<Mutex<Vec<String>>>, 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:?}"),
|
|
}
|
|
}
|
|
}
|