feat: add websocket browser service runtime

Wire the service/browser runtime onto the websocket-driven execution path and add the new browser/service modules needed for the submit flow and runtime integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-04 23:42:27 +08:00
parent 2ae71fb1c9
commit 3e18350320
33 changed files with 4993 additions and 327 deletions

39
src/browser/backend.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::sync::Arc;
use serde_json::Value;
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError};
pub trait BrowserBackend: Send + Sync {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError>;
fn surface_metadata(&self) -> ExecutionSurfaceMetadata;
fn supports_eval(&self) -> bool {
true
}
}
impl<T: BrowserBackend + ?Sized> BrowserBackend for Arc<T> {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.as_ref().invoke(action, params, expected_domain)
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.as_ref().surface_metadata()
}
fn supports_eval(&self) -> bool {
self.as_ref().supports_eval()
}
}

View File

@@ -0,0 +1,66 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::browser::backend::BrowserBackend;
use crate::browser::bridge_contract::{BridgeBrowserActionReply, BridgeBrowserActionRequest};
use crate::browser::bridge_transport::BridgeActionTransport;
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError};
use crate::security::MacPolicy;
pub struct BridgeBrowserBackend {
transport: Arc<dyn BridgeActionTransport>,
mac_policy: MacPolicy,
next_seq: AtomicU64,
}
impl BridgeBrowserBackend {
pub fn new(transport: Arc<dyn BridgeActionTransport>, mac_policy: MacPolicy) -> Self {
Self {
transport,
mac_policy,
next_seq: AtomicU64::new(1),
}
}
}
impl BrowserBackend for BridgeBrowserBackend {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.mac_policy.validate(&action, expected_domain)?;
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
let reply = self.transport.execute(BridgeBrowserActionRequest::new(
action.as_str(),
params,
expected_domain,
))?;
match reply {
BridgeBrowserActionReply::Success(success) => Ok(CommandOutput {
seq,
success: true,
data: success.data,
aom_snapshot: success.aom_snapshot,
timing: success.timing,
}),
BridgeBrowserActionReply::Error(error) => Err(PipeError::Protocol(format!(
"bridge action failed: {}",
error.message
))),
}
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.mac_policy.privileged_surface_metadata()
}
fn supports_eval(&self) -> bool {
self.mac_policy.supports_pipe_action(&Action::Eval)
}
}

View File

@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::pipe::Timing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeLifecycleCall {
Connect,
Start,
Stop,
SubmitTask,
}
impl BridgeLifecycleCall {
pub fn bridge_name(self) -> &'static str {
match self {
Self::Connect => "sgclawConnect",
Self::Start => "sgclawStart",
Self::Stop => "sgclawStop",
Self::SubmitTask => "sgclawSubmitTask",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BridgeBrowserActionRequest {
pub action: String,
pub params: Value,
pub expected_domain: String,
}
impl BridgeBrowserActionRequest {
pub fn new(
action: impl Into<String>,
params: Value,
expected_domain: impl Into<String>,
) -> Self {
Self {
action: action.into(),
params,
expected_domain: expected_domain.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BridgeBrowserActionReply {
Success(BridgeBrowserActionSuccess),
Error(BridgeBrowserActionError),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BridgeBrowserActionSuccess {
pub data: Value,
pub aom_snapshot: Vec<Value>,
pub timing: Timing,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BridgeBrowserActionError {
pub message: String,
pub details: Value,
}

View File

@@ -0,0 +1,9 @@
use crate::browser::bridge_contract::{BridgeBrowserActionReply, BridgeBrowserActionRequest};
use crate::pipe::PipeError;
pub trait BridgeActionTransport: Send + Sync {
fn execute(
&self,
request: BridgeBrowserActionRequest,
) -> Result<BridgeBrowserActionReply, PipeError>;
}

View File

@@ -0,0 +1,301 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::browser::backend::BrowserBackend;
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
use crate::security::MacPolicy;
const NAVIGATE_CALLBACK_NAME: &str = "sgclawOnLoaded";
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
const SHOW_AREA: &str = "show";
pub trait BrowserCallbackHost: Send + Sync {
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError>;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserCallbackRequest {
pub seq: u64,
pub request_url: String,
pub expected_domain: String,
pub action: String,
pub command: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BrowserCallbackResponse {
Success(BrowserCallbackSuccess),
Error(BrowserCallbackError),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserCallbackSuccess {
pub success: bool,
pub data: Value,
pub aom_snapshot: Vec<Value>,
pub timing: Timing,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserCallbackError {
pub message: String,
pub details: Value,
}
pub struct BrowserCallbackBackend {
host: Arc<dyn BrowserCallbackHost>,
mac_policy: MacPolicy,
helper_page_url: String,
current_target_url: Mutex<Option<String>>,
next_seq: AtomicU64,
}
impl BrowserCallbackBackend {
pub fn new(
host: Arc<dyn BrowserCallbackHost>,
mac_policy: MacPolicy,
helper_page_url: impl Into<String>,
) -> Self {
Self {
host,
mac_policy,
helper_page_url: helper_page_url.into(),
current_target_url: Mutex::new(None),
next_seq: AtomicU64::new(1),
}
}
fn build_command(&self, action: &Action, params: &Value) -> Result<Value, PipeError> {
match action {
Action::Navigate => {
let target_url = required_string(params, "url")?;
// Use sgBrowerserOpenPage to open the target URL in a **new**
// visible browser tab. This keeps the helper page alive so its
// WebSocket connection, command polling, and callback functions
// remain functional for subsequent GetText / Eval commands.
//
// sgBrowserCallAfterLoaded would navigate the helper page tab
// itself to the target URL, destroying all helper-page JS
// context and making further communication impossible.
//
// sgBrowerserOpenPage does not fire a JS callback; the callback
// host will treat the navigate action as fire-and-forget and
// return success once the command has been forwarded.
Ok(json!([
self.helper_page_url,
"sgBrowerserOpenPage",
target_url,
]))
}
Action::GetText => {
let target_url = self.target_url(action, params)?;
let domain = extract_domain(&target_url)?;
let selector = required_string(params, "selector")?;
let js_code = build_get_text_js(&self.helper_page_url, &selector);
// Use sgBrowserExcuteJsCodeByDomain (API #25) which matches
// pages by domain rather than exact URL. This is far more
// robust than sgBrowserExcuteJsCodeByArea because the actual
// page URL may differ from what we navigated to (redirects,
// query parameters, etc.).
Ok(json!([
self.helper_page_url,
"sgBrowserExcuteJsCodeByDomain",
domain,
js_code,
SHOW_AREA,
]))
}
Action::Eval => {
let target_url = self.target_url(action, params)?;
let domain = extract_domain(&target_url)?;
let script = required_string(params, "script")?;
let js_code = build_eval_js(&self.helper_page_url, &script);
Ok(json!([
self.helper_page_url,
"sgBrowserExcuteJsCodeByDomain",
domain,
js_code,
SHOW_AREA,
]))
}
_ => Err(PipeError::Protocol(format!(
"unsupported callback-host browser action: {}",
action.as_str()
))),
}
}
fn target_url(&self, action: &Action, params: &Value) -> Result<String, PipeError> {
if let Some(target_url) = params
.get("target_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
{
return Ok(target_url);
}
self.current_target_url
.lock()
.map_err(|_| PipeError::Protocol("callback backend target url lock poisoned".to_string()))?
.clone()
.ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
}
}
impl BrowserBackend for BrowserCallbackBackend {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.mac_policy.validate(&action, expected_domain)?;
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
let reply = self.host.execute(BrowserCallbackRequest {
seq,
request_url: self.helper_page_url.clone(),
expected_domain: expected_domain.to_string(),
action: action.as_str().to_string(),
command: self.build_command(&action, &params)?,
})?;
match reply {
BrowserCallbackResponse::Success(success) => {
if matches!(action, Action::Navigate) {
if let Some(url) = params
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
*self.current_target_url.lock().map_err(|_| {
PipeError::Protocol("callback backend target url lock poisoned".to_string())
})? = Some(url.to_string());
}
}
Ok(CommandOutput {
seq,
success: success.success,
data: success.data,
aom_snapshot: success.aom_snapshot,
timing: success.timing,
})
}
BrowserCallbackResponse::Error(error) => Err(PipeError::Protocol(format!(
"callback host browser action failed: {} ({})",
error.message, error.details
))),
}
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.mac_policy.privileged_surface_metadata()
}
fn supports_eval(&self) -> bool {
self.mac_policy.supports_pipe_action(&Action::Eval)
}
}
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
params
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
}
fn build_get_text_js(source_url: &str, selector: &str) -> String {
let escaped_source_url = escape_js_single_quoted(source_url);
let escaped_selector = escape_js_single_quoted(selector);
let callback = GET_TEXT_CALLBACK_NAME;
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
// Three delivery paths for getting the result back to the callback host:
//
// 1. callBackJsToCpp (API #40) — browser-native IPC that routes the
// callback function to the helper page.
// 2. XMLHttpRequest POST to callback host — localhost (127.0.0.1) is
// exempt from mixed-content restrictions in Chromium.
// 3. navigator.sendBeacon fallback — same localhost exemption.
//
// The XHR / sendBeacon paths POST the event DIRECTLY in the format the
// callback host expects (callback="sgclawOnGetText", payload={text:...})
// so normalize_callback_result can process it via Path A.
format!(
"(function(){{try{{\
var el=document.querySelector('{escaped_selector}');\
var t=el?((el.innerText||el.textContent||'').trim()):'';\
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+t)}}catch(_){{}}\
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{text:t}}}});\
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
}}catch(e){{}}}})()"
)
}
fn build_eval_js(source_url: &str, script: &str) -> String {
let escaped_source_url = escape_js_single_quoted(source_url);
let callback = EVAL_CALLBACK_NAME;
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
format!(
"(function(){{try{{var v=(function(){{return {script}}})();\
var t=(typeof v==='string')?v:JSON.stringify(v);\
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
}}catch(e){{}}}})()"
)
}
/// Derive the callback host events endpoint URL from the helper page URL.
/// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html"
/// → "http://127.0.0.1:62819/sgclaw/callback/events"
fn events_endpoint_url(helper_page_url: &str) -> String {
let origin = helper_page_url
.find("://")
.and_then(|scheme_end| {
helper_page_url[scheme_end + 3..]
.find('/')
.map(|path_start| &helper_page_url[..scheme_end + 3 + path_start])
})
.unwrap_or(helper_page_url);
format!("{origin}/sgclaw/callback/events")
}
/// Extract the domain from a URL.
/// e.g. "https://www.zhihu.com/hot" → "www.zhihu.com"
fn extract_domain(url: &str) -> Result<String, PipeError> {
let after_scheme = url
.find("://")
.map(|i| &url[i + 3..])
.unwrap_or(url);
let domain = after_scheme
.split('/')
.next()
.unwrap_or(after_scheme)
.split(':')
.next()
.unwrap_or(after_scheme);
if domain.is_empty() {
return Err(PipeError::Protocol(format!(
"failed to extract domain from URL: {url}"
)));
}
Ok(domain.to_string())
}
fn escape_js_single_quoted(raw: &str) -> String {
raw.replace('\\', "\\\\").replace('\'', "\\'")
}

1105
src/browser/callback_host.rs Normal file

File diff suppressed because it is too large Load Diff

19
src/browser/mod.rs Normal file
View File

@@ -0,0 +1,19 @@
pub mod bridge_backend;
pub mod bridge_contract;
pub mod bridge_transport;
pub mod callback_backend;
mod backend;
pub(crate) mod callback_host;
mod pipe_backend;
pub mod ws_backend;
pub mod ws_probe;
pub mod ws_protocol;
pub use backend::BrowserBackend;
pub use bridge_backend::BridgeBrowserBackend;
pub use callback_backend::{
BrowserCallbackBackend, BrowserCallbackError, BrowserCallbackHost,
BrowserCallbackRequest, BrowserCallbackResponse, BrowserCallbackSuccess,
};
pub use pipe_backend::PipeBrowserBackend;
pub use ws_backend::WsBrowserBackend;

View File

@@ -0,0 +1,55 @@
use std::sync::Arc;
use serde_json::Value;
use crate::browser::BrowserBackend;
use crate::pipe::{Action, BrowserPipeTool, CommandOutput, ExecutionSurfaceMetadata, PipeError, Transport};
use crate::security::MacPolicy;
pub struct PipeBrowserBackend<T: Transport> {
inner: BrowserPipeTool<T>,
}
impl<T: Transport> PipeBrowserBackend<T> {
pub fn new(transport: Arc<T>, mac_policy: MacPolicy, session_key: Vec<u8>) -> Self {
Self {
inner: BrowserPipeTool::new(transport, mac_policy, session_key),
}
}
pub fn from_inner(inner: BrowserPipeTool<T>) -> Self {
Self { inner }
}
pub fn with_response_timeout(mut self, response_timeout: std::time::Duration) -> Self {
self.inner = self.inner.with_response_timeout(response_timeout);
self
}
}
impl<T: Transport> Clone for PipeBrowserBackend<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T: Transport> BrowserBackend for PipeBrowserBackend<T> {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.inner.invoke(action, params, expected_domain)
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.inner.surface_metadata()
}
fn supports_eval(&self) -> bool {
self.inner.supports_eval()
}
}

158
src/browser/ws_backend.rs Normal file
View File

@@ -0,0 +1,158 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{json, Value};
use crate::browser::{ws_protocol, BrowserBackend};
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
use crate::security::MacPolicy;
pub trait WsClient: Send + Sync {
fn send_text(&self, payload: &str) -> Result<(), PipeError>;
fn recv_text_timeout(&self, timeout: Duration) -> Result<String, PipeError>;
}
pub struct WsBrowserBackend<C: WsClient> {
client: Arc<C>,
mac_policy: MacPolicy,
request_url: Mutex<String>,
next_seq: AtomicU64,
response_timeout: Duration,
in_flight: Mutex<()>,
}
impl<C: WsClient> WsBrowserBackend<C> {
pub fn new(client: Arc<C>, mac_policy: MacPolicy, request_url: impl Into<String>) -> Self {
Self {
client,
mac_policy,
request_url: Mutex::new(request_url.into()),
next_seq: AtomicU64::new(1),
response_timeout: Duration::from_secs(30),
in_flight: Mutex::new(()),
}
}
pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self {
self.response_timeout = response_timeout;
self
}
}
impl<C: WsClient> BrowserBackend for WsBrowserBackend<C> {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
let _guard = self
.in_flight
.lock()
.map_err(|_| PipeError::Protocol("browser ws request lock poisoned".to_string()))?;
self.mac_policy.validate(&action, expected_domain)?;
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
let request_id = seq.to_string();
let request_url = self
.request_url
.lock()
.map_err(|_| PipeError::Protocol("browser ws request url lock poisoned".to_string()))?
.clone();
let encoded = ws_protocol::encode_v1_action(
&action,
&params,
&request_url,
Some(request_id.as_str()),
)?;
self.client.send_text(&encoded.payload)?;
let status = Some(recv_status_frame(&*self.client, self.response_timeout)?);
if let Some(status) = status {
let status_code = parse_status_code(&status)?;
if status_code != 0 {
return Err(PipeError::Protocol(format!(
"browser returned non-zero status: {status_code}"
)));
}
}
if action == Action::Navigate {
if let Some(url) = params.get("url").and_then(Value::as_str) {
let mut request_url = self.request_url.lock().map_err(|_| {
PipeError::Protocol("browser ws request url lock poisoned".to_string())
})?;
*request_url = url.to_string();
}
}
if let Some(callback) = encoded.callback {
loop {
let frame = self.client.recv_text_timeout(self.response_timeout)?;
let decoded = ws_protocol::decode_callback_frame(&frame)?;
if decoded.callback_name == callback.callback_name {
return Ok(CommandOutput {
seq,
success: true,
data: json!({ "text": decoded.response_text }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 0,
exec_ms: 0,
},
});
}
}
}
Ok(CommandOutput {
seq,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 0,
exec_ms: 0,
},
})
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
self.mac_policy.privileged_surface_metadata()
}
fn supports_eval(&self) -> bool {
self.mac_policy.supports_pipe_action(&Action::Eval)
}
}
fn parse_status_code(raw: &str) -> Result<i64, PipeError> {
raw.trim()
.parse::<i64>()
.map_err(|_| PipeError::Protocol(format!("invalid browser status frame: {raw}")))
}
fn recv_status_frame(client: &dyn WsClient, timeout: Duration) -> Result<String, PipeError> {
loop {
let frame = client.recv_text_timeout(timeout)?;
if is_ignorable_status_prelude(&frame) {
continue;
}
return Ok(frame);
}
}
fn is_ignorable_status_prelude(frame: &str) -> bool {
let trimmed = frame.trim();
if trimmed.starts_with("Welcome!") || trimmed.starts_with("Welcome ") {
return true;
}
serde_json::from_str::<Value>(trimmed)
.ok()
.and_then(|value| value.get("type").and_then(Value::as_str).map(str::to_string))
.is_some_and(|kind| kind == "welcome")
}

307
src/browser/ws_probe.rs Normal file
View File

@@ -0,0 +1,307 @@
use std::net::TcpStream;
use std::time::Duration;
use thiserror::Error;
use tungstenite::stream::MaybeTlsStream;
use tungstenite::{connect, Message, WebSocket};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeStep {
pub label: String,
pub payload: String,
pub expect_reply: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Received(Vec<String>),
NoReplyExpected,
TimedOut,
Closed,
ConnectFailed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeStepResult {
pub label: String,
pub sent: String,
pub outcome: ProbeOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeCliConfig {
pub ws_url: String,
pub timeout_ms: u64,
pub steps: Vec<ProbeStep>,
}
const DEFAULT_TIMEOUT_MS: u64 = 1500;
const DEFAULT_REGISTER_STEP_LABEL: &str = "register";
const DEFAULT_REGISTER_STEP_PAYLOAD: &str = r#"{"type":"register","role":"web"}"#;
#[derive(Debug, Error)]
pub enum ProbeError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("probe timeout while waiting for websocket frame")]
Timeout,
#[error("probe websocket closed")]
Closed,
#[error("probe protocol error: {0}")]
Protocol(String),
#[error("probe argument error: {0}")]
Args(String),
}
pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError> {
let mut ws_url = None;
let mut timeout_ms = None;
let mut steps = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--ws-url" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| ProbeError::Args("missing value for --ws-url".to_string()))?;
ws_url = Some(value.clone());
}
"--timeout-ms" => {
index += 1;
let value = args.get(index).ok_or_else(|| {
ProbeError::Args("missing value for --timeout-ms".to_string())
})?;
let parsed = value.parse::<u64>().map_err(|_| {
ProbeError::Args(format!("invalid --timeout-ms value: {value}"))
})?;
timeout_ms = Some(parsed);
}
"--step" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| ProbeError::Args("missing value for --step".to_string()))?;
let (label, payload) = value.split_once("::").ok_or_else(|| {
ProbeError::Args(format!(
"invalid --step value (expected <label>::<payload>): {value}"
))
})?;
if label.is_empty() {
return Err(ProbeError::Args("step label must not be empty".to_string()));
}
if payload.is_empty() {
return Err(ProbeError::Args("step payload must not be empty".to_string()));
}
steps.push(ProbeStep {
label: label.to_string(),
payload: payload.to_string(),
expect_reply: true,
});
}
flag => {
return Err(ProbeError::Args(format!("unknown argument: {flag}")));
}
}
index += 1;
}
let ws_url = ws_url.ok_or_else(|| ProbeError::Args("missing required --ws-url".to_string()))?;
validate_ws_url(&ws_url)?;
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
if steps.is_empty() {
steps.push(ProbeStep {
label: DEFAULT_REGISTER_STEP_LABEL.to_string(),
payload: DEFAULT_REGISTER_STEP_PAYLOAD.to_string(),
expect_reply: true,
});
}
Ok(ProbeCliConfig {
ws_url,
timeout_ms,
steps,
})
}
fn validate_ws_url(ws_url: &str) -> Result<(), ProbeError> {
if ws_url.starts_with("ws://") {
return Ok(());
}
Err(ProbeError::Args(format!(
"unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
)))
}
pub fn run_probe_script(
ws_url: &str,
timeout: Duration,
steps: Vec<ProbeStep>,
) -> Result<Vec<ProbeStepResult>, ProbeError> {
let mut socket = match connect(ws_url) {
Ok((socket, _)) => socket,
Err(err) => {
let message = err.to_string();
return Ok(steps
.into_iter()
.map(|step| ProbeStepResult {
label: step.label,
sent: step.payload,
outcome: ProbeOutcome::ConnectFailed(message.clone()),
})
.collect());
}
};
configure_socket_timeout(&mut socket, timeout)?;
let mut results = Vec::with_capacity(steps.len());
for step in steps {
let ProbeStep {
label,
payload,
expect_reply,
} = step;
let send_outcome = match socket.send(Message::Text(payload.clone().into())) {
Ok(()) => None,
Err(err) => Some(map_websocket_error(err, "browser websocket send")),
};
let outcome = match send_outcome {
Some(ProbeError::Timeout) => ProbeOutcome::TimedOut,
Some(ProbeError::Closed) => ProbeOutcome::Closed,
Some(err) => return Err(err),
None if expect_reply => match read_probe_frames(&mut socket) {
Ok(frames) => ProbeOutcome::Received(frames),
Err(ProbeError::Timeout) => ProbeOutcome::TimedOut,
Err(ProbeError::Closed) => ProbeOutcome::Closed,
Err(err) => return Err(err),
},
None => ProbeOutcome::NoReplyExpected,
};
results.push(ProbeStepResult {
label,
sent: payload,
outcome,
});
}
Ok(results)
}
fn configure_socket_timeout(
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
timeout: Duration,
) -> Result<(), ProbeError> {
match websocket.get_mut() {
MaybeTlsStream::Plain(stream) => {
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
Ok(())
}
_ => Ok(()),
}
}
fn read_probe_frames(
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
) -> Result<Vec<String>, ProbeError> {
let first_frame = read_probe_frame(websocket)?;
let mut frames = vec![first_frame];
let Some(original_timeout) = get_plain_read_timeout(websocket)? else {
return Ok(frames);
};
set_plain_read_timeout(websocket, Some(Duration::from_millis(1)))?;
loop {
match read_probe_frame(websocket) {
Ok(frame) => frames.push(frame),
Err(ProbeError::Timeout) | Err(ProbeError::Closed) => break,
Err(err) => {
set_plain_read_timeout(websocket, original_timeout)?;
return Err(err);
}
}
}
set_plain_read_timeout(websocket, original_timeout)?;
Ok(frames)
}
fn get_plain_read_timeout(
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
) -> Result<Option<Option<Duration>>, ProbeError> {
match websocket.get_mut() {
MaybeTlsStream::Plain(stream) => Ok(Some(stream.read_timeout()?)),
_ => Ok(None),
}
}
fn set_plain_read_timeout(
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
timeout: Option<Duration>,
) -> Result<(), ProbeError> {
match websocket.get_mut() {
MaybeTlsStream::Plain(stream) => {
stream.set_read_timeout(timeout)?;
Ok(())
}
_ => Ok(()),
}
}
fn read_probe_frame(
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
) -> Result<String, ProbeError> {
loop {
match websocket.read() {
Ok(Message::Text(text)) => return Ok(text.to_string()),
Ok(Message::Close(_)) => return Err(ProbeError::Closed),
Ok(Message::Ping(payload)) => {
websocket
.send(Message::Pong(payload))
.map_err(|err| map_websocket_error(err, "browser websocket pong"))?;
}
Ok(_) => {}
Err(err) => return Err(map_websocket_error(err, "browser websocket read")),
}
}
}
fn map_websocket_error(err: tungstenite::Error, operation: &str) -> ProbeError {
match err {
tungstenite::Error::ConnectionClosed
| tungstenite::Error::AlreadyClosed
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing) => {
ProbeError::Closed
}
tungstenite::Error::Io(io_err)
if matches!(
io_err.kind(),
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
) =>
{
ProbeError::Timeout
}
tungstenite::Error::Io(io_err)
if matches!(
io_err.kind(),
std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::UnexpectedEof
) =>
{
ProbeError::Closed
}
tungstenite::Error::Io(io_err) => ProbeError::Io(io_err),
other => ProbeError::Protocol(format!("{operation} failed: {other}")),
}
}

306
src/browser/ws_protocol.rs Normal file
View File

@@ -0,0 +1,306 @@
use serde_json::{json, Value};
use crate::pipe::{Action, PipeError};
const CALLBACK_DELIMITER: &str = "@_@";
const CALLBACK_PREFIX: &str = "sgclaw_cb_";
const JS_AREA_HIDE: &str = "hide";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallbackCorrelation {
pub request_id: String,
pub callback_name: String,
pub source_url: String,
pub target_url: String,
pub action_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedWsRequest {
pub payload: String,
pub callback: Option<CallbackCorrelation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedCallback {
pub source_url: String,
pub target_url: String,
pub callback_name: String,
pub action_url: String,
pub response_text: String,
}
pub fn encode_v1_action(
action: &Action,
params: &Value,
request_url: &str,
request_id: Option<&str>,
) -> Result<EncodedWsRequest, PipeError> {
match action {
Action::Navigate => encode_navigate(params, request_url, request_id),
Action::Click => encode_click(params, request_url),
Action::Type => encode_type(params, request_url),
Action::GetText => encode_get_text(params, request_url, request_id),
Action::Eval => encode_eval(params, request_url, request_id),
_ => Err(PipeError::Protocol(format!(
"unsupported browser ws action: {}",
action.as_str()
))),
}
}
pub fn decode_callback_frame(frame: &str) -> Result<DecodedCallback, PipeError> {
let payload: Value = serde_json::from_str(frame)?;
let array = payload.as_array().ok_or_else(|| {
PipeError::Protocol("callback frame must be a JSON array".to_string())
})?;
if array.len() != 3 {
return Err(PipeError::Protocol(
"callback frame must contain [requesturl, function, payload]".to_string(),
));
}
let function_name = array[1].as_str().ok_or_else(|| {
PipeError::Protocol("callback frame function name must be a string".to_string())
})?;
if function_name != "callBackJsToCpp" {
return Err(PipeError::Protocol(
"callback frame must target callBackJsToCpp".to_string(),
));
}
let param = array[2].as_str().ok_or_else(|| {
PipeError::Protocol("callback payload must be a string".to_string())
})?;
let mut parts = param.splitn(5, CALLBACK_DELIMITER);
let source_url = parts.next().unwrap_or_default();
let target_url = parts.next().unwrap_or_default();
let callback_name = parts.next().unwrap_or_default();
let action_url = parts.next().unwrap_or_default();
let response_text = parts.next().unwrap_or_default();
if source_url.is_empty()
|| target_url.is_empty()
|| callback_name.is_empty()
|| action_url.is_empty()
|| response_text.is_empty() && !param.ends_with(CALLBACK_DELIMITER)
{
return Err(PipeError::Protocol(
"malformed callback payload".to_string(),
));
}
Ok(DecodedCallback {
source_url: source_url.to_string(),
target_url: target_url.to_string(),
callback_name: callback_name.to_string(),
action_url: action_url.to_string(),
response_text: response_text.to_string(),
})
}
fn encode_navigate(
params: &Value,
request_url: &str,
request_id: Option<&str>,
) -> Result<EncodedWsRequest, PipeError> {
let url = required_string(params, "url")?;
let callback = callback_metadata(
request_id,
request_url,
&url,
"sgHideBrowserCallAfterLoaded",
)?;
let callback_call = format!(
"callBackJsToCpp(\"{request_url}@_@{url}@_@{callback_name}@_@sgHideBrowserCallAfterLoaded@_@\")",
callback_name = callback.callback_name,
);
Ok(EncodedWsRequest {
payload: serde_json::to_string(&json!([
request_url,
"sgHideBrowserCallAfterLoaded",
url,
callback_call,
]))?,
callback: Some(callback),
})
}
fn encode_click(params: &Value, request_url: &str) -> Result<EncodedWsRequest, PipeError> {
let target_url = target_url(params, request_url)?;
let selector = required_string(params, "selector")?;
let script = format!(
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}el.click();}})();"
);
encode_js_in_area(request_url, &target_url, &script, None)
}
fn encode_type(params: &Value, request_url: &str) -> Result<EncodedWsRequest, PipeError> {
let target_url = target_url(params, request_url)?;
let selector = required_string(params, "selector")?;
let text = required_string(params, "text")?;
let script = format!(
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}el.value={text:?};el.dispatchEvent(new Event(\"input\",{{bubbles:true}}));el.dispatchEvent(new Event(\"change\",{{bubbles:true}}));}})();"
);
encode_js_in_area(request_url, &target_url, &script, None)
}
fn encode_get_text(
params: &Value,
request_url: &str,
request_id: Option<&str>,
) -> Result<EncodedWsRequest, PipeError> {
let target_url = target_url(params, request_url)?;
let selector = required_string(params, "selector")?;
let callback = callback_metadata(
request_id,
request_url,
&target_url,
"sgBrowserExcuteJsCodeByArea",
)?;
let script = format!(
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}const text=el.innerText ?? el.textContent ?? \"\";callBackJsToCpp(\"{request_url}@_@{target_url}@_@{callback_name}@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text));}})();",
callback_name = callback.callback_name
);
encode_js_in_area(request_url, &target_url, &script, Some(callback))
}
fn encode_eval(
params: &Value,
request_url: &str,
request_id: Option<&str>,
) -> Result<EncodedWsRequest, PipeError> {
let target_url = target_url(params, request_url)?;
let source_script = required_string(params, "script")?;
let callback = callback_metadata(
request_id,
request_url,
&target_url,
"sgBrowserExcuteJsCodeByArea",
)?;
let script = format!(
"(function(){{const result=(function(){{{source_script}}})();callBackJsToCpp(\"{request_url}@_@{target_url}@_@{callback_name}@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result));}})();",
callback_name = callback.callback_name
);
encode_js_in_area(request_url, &target_url, &script, Some(callback))
}
fn encode_js_in_area(
request_url: &str,
target_url: &str,
script: &str,
callback: Option<CallbackCorrelation>,
) -> Result<EncodedWsRequest, PipeError> {
Ok(EncodedWsRequest {
payload: serde_json::to_string(&json!([
request_url,
"sgBrowserExcuteJsCodeByArea",
target_url,
script,
JS_AREA_HIDE,
]))?,
callback,
})
}
fn callback_metadata(
request_id: Option<&str>,
request_url: &str,
target_url: &str,
action_url: &str,
) -> Result<CallbackCorrelation, PipeError> {
let request_id = request_id
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PipeError::Protocol("request_id is required".to_string()))?;
Ok(CallbackCorrelation {
request_id: request_id.to_string(),
callback_name: format!("{CALLBACK_PREFIX}{request_id}"),
source_url: request_url.to_string(),
target_url: target_url.to_string(),
action_url: action_url.to_string(),
})
}
fn target_url(params: &Value, request_url: &str) -> Result<String, PipeError> {
Ok(optional_string(params, "target_url")
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| request_url.to_string()))
}
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
optional_string(params, key)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
}
fn optional_string(params: &Value, key: &str) -> Option<String> {
params.get(key)?.as_str().map(ToString::to_string)
}
#[cfg(test)]
mod tests {
use super::{decode_callback_frame, encode_v1_action};
use crate::pipe::Action;
use serde_json::{json, Value};
#[test]
fn get_text_callback_uses_documented_browser_opcode() {
let request = encode_v1_action(
&Action::GetText,
&json!({
"target_url": "https://www.zhihu.com/hot",
"selector": "#content"
}),
"https://www.zhihu.com/hot",
Some("req42"),
)
.unwrap();
let payload: Value = serde_json::from_str(&request.payload).unwrap();
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(payload[4], json!("hide"));
assert_eq!(
request.callback.unwrap().action_url,
"sgBrowserExcuteJsCodeByArea"
);
assert!(payload[3].as_str().unwrap().contains(
"callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text))"
));
}
#[test]
fn eval_callback_uses_documented_browser_opcode() {
let request = encode_v1_action(
&Action::Eval,
&json!({
"target_url": "https://www.zhihu.com/hot",
"script": "2 + 2"
}),
"https://www.zhihu.com/hot",
Some("req-eval"),
)
.unwrap();
let payload: Value = serde_json::from_str(&request.payload).unwrap();
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(
request.callback.unwrap().action_url,
"sgBrowserExcuteJsCodeByArea"
);
assert!(payload[3].as_str().unwrap().contains(
"callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req-eval@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result))"
));
}
#[test]
fn decodes_documented_callback_payload() {
let callback = decode_callback_frame(
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
)
.unwrap();
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
assert_eq!(callback.response_text, "天气");
}
}