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

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('\'', "\\'")
}