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:
301
src/browser/callback_backend.rs
Normal file
301
src/browser/callback_backend.rs
Normal 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, ¶ms)?,
|
||||
})?;
|
||||
|
||||
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('\'', "\\'")
|
||||
}
|
||||
Reference in New Issue
Block a user