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:
39
src/browser/backend.rs
Normal file
39
src/browser/backend.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
66
src/browser/bridge_backend.rs
Normal file
66
src/browser/bridge_backend.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
63
src/browser/bridge_contract.rs
Normal file
63
src/browser/bridge_contract.rs
Normal 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,
|
||||
}
|
||||
9
src/browser/bridge_transport.rs
Normal file
9
src/browser/bridge_transport.rs
Normal 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>;
|
||||
}
|
||||
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('\'', "\\'")
|
||||
}
|
||||
1105
src/browser/callback_host.rs
Normal file
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
19
src/browser/mod.rs
Normal 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;
|
||||
55
src/browser/pipe_backend.rs
Normal file
55
src/browser/pipe_backend.rs
Normal 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
158
src/browser/ws_backend.rs
Normal 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,
|
||||
¶ms,
|
||||
&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
307
src/browser/ws_probe.rs
Normal 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
306
src/browser/ws_protocol.rs
Normal 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, "天气");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user