- Auto-connect WebSocket on page load in service console - Settings modal for editing sgclaw_config.json (API key, base URL, model, skills dir, etc.) - UpdateConfig/ConfigUpdated protocol messages for remote config save - save_to_path() for SgClawSettings serialization - ConfigUpdated handler in sg_claw_client binary - Protocol serialization tests for new message types - HTML test assertions for auto-connect and settings UI - Additional pending changes: deterministic submit, org units, lineloss xlsx export, browser script tool, and docs 🤖 Generated with [Qoder][https://qoder.com]
975 lines
36 KiB
Rust
975 lines
36 KiB
Rust
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 CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
|
|
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
|
|
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
|
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
|
const SHOW_AREA: &str = "show";
|
|
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
|
const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
|
const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
|
|
|
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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum CallbackInputMode {
|
|
Click,
|
|
Type,
|
|
}
|
|
|
|
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::Click => self.build_input_command(action, params, CallbackInputMode::Click),
|
|
Action::Type => self.build_input_command(action, params, CallbackInputMode::Type),
|
|
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 build_input_command(
|
|
&self,
|
|
action: &Action,
|
|
params: &Value,
|
|
mode: CallbackInputMode,
|
|
) -> Result<Value, PipeError> {
|
|
let target_url = self.target_url(action, params)?;
|
|
let domain = extract_domain(&target_url)?;
|
|
let selector = optional_string(params, "selector");
|
|
let probe_script = optional_string(params, "probe_script");
|
|
let text = matches!(mode, CallbackInputMode::Type)
|
|
.then(|| required_string(params, "text"))
|
|
.transpose()?;
|
|
let js_code = build_input_probe_js(
|
|
mode,
|
|
&self.helper_page_url,
|
|
selector.as_deref(),
|
|
probe_script.as_deref(),
|
|
text.as_deref(),
|
|
)?;
|
|
Ok(json!([
|
|
self.helper_page_url,
|
|
"sgBrowserExcuteJsCodeByDomain",
|
|
domain,
|
|
js_code,
|
|
SHOW_AREA,
|
|
]))
|
|
}
|
|
|
|
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())))
|
|
}
|
|
|
|
fn execute_simulated_click(
|
|
&self,
|
|
seq: u64,
|
|
expected_domain: &str,
|
|
success: &BrowserCallbackSuccess,
|
|
) -> Result<BrowserCallbackSuccess, PipeError> {
|
|
let probe = success
|
|
.data
|
|
.get("probe")
|
|
.ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?;
|
|
let x = probe
|
|
.get("x")
|
|
.and_then(Value::as_f64)
|
|
.ok_or_else(|| PipeError::Protocol("callback click probe missing x".to_string()))?;
|
|
let y = probe
|
|
.get("y")
|
|
.and_then(Value::as_f64)
|
|
.ok_or_else(|| PipeError::Protocol("callback click probe missing y".to_string()))?;
|
|
let timing = success.timing.clone();
|
|
match self.host.execute(BrowserCallbackRequest {
|
|
seq,
|
|
request_url: self.helper_page_url.clone(),
|
|
expected_domain: expected_domain.to_string(),
|
|
action: Action::Click.as_str().to_string(),
|
|
command: json!([
|
|
self.helper_page_url,
|
|
"sgBroewserSimulateMouse",
|
|
x,
|
|
y,
|
|
"left",
|
|
"",
|
|
""
|
|
]),
|
|
}) {
|
|
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
|
"callback host browser action failed: {} ({})",
|
|
error.message, error.details
|
|
))),
|
|
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
|
Ok(BrowserCallbackSuccess {
|
|
success: true,
|
|
data: json!({
|
|
"clicked": true,
|
|
"probe": { "x": x, "y": y },
|
|
}),
|
|
aom_snapshot: vec![],
|
|
timing,
|
|
})
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
fn execute_simulated_type(
|
|
&self,
|
|
seq: u64,
|
|
expected_domain: &str,
|
|
params: &Value,
|
|
success: &BrowserCallbackSuccess,
|
|
) -> Result<BrowserCallbackSuccess, PipeError> {
|
|
let probe = success
|
|
.data
|
|
.get("probe")
|
|
.ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?;
|
|
let x = probe
|
|
.get("x")
|
|
.and_then(Value::as_f64)
|
|
.ok_or_else(|| PipeError::Protocol("callback type probe missing x".to_string()))?;
|
|
let y = probe
|
|
.get("y")
|
|
.and_then(Value::as_f64)
|
|
.ok_or_else(|| PipeError::Protocol("callback type probe missing y".to_string()))?;
|
|
let text = params
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| PipeError::Protocol("text is required".to_string()))?;
|
|
let timing = success.timing.clone();
|
|
match self.host.execute(BrowserCallbackRequest {
|
|
seq,
|
|
request_url: self.helper_page_url.clone(),
|
|
expected_domain: expected_domain.to_string(),
|
|
action: Action::Type.as_str().to_string(),
|
|
command: json!([
|
|
self.helper_page_url,
|
|
"sgBroewserSimulateKeyborad",
|
|
x,
|
|
y,
|
|
text
|
|
]),
|
|
}) {
|
|
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
|
"callback host browser action failed: {} ({})",
|
|
error.message, error.details
|
|
))),
|
|
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
|
Ok(BrowserCallbackSuccess {
|
|
success: true,
|
|
data: json!({
|
|
"typed": true,
|
|
"probe": { "x": x, "y": y, "text": text },
|
|
}),
|
|
aom_snapshot: vec![],
|
|
timing,
|
|
})
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BrowserBackend for BrowserCallbackBackend {
|
|
fn invoke(
|
|
&self,
|
|
action: Action,
|
|
params: Value,
|
|
expected_domain: &str,
|
|
) -> Result<CommandOutput, PipeError> {
|
|
if let Some(local_dashboard) = approved_local_dashboard_request(&action, ¶ms, expected_domain)
|
|
{
|
|
self.mac_policy
|
|
.validate_local_dashboard_presentation(
|
|
&action,
|
|
expected_domain,
|
|
&local_dashboard.presentation_url,
|
|
&local_dashboard.output_path,
|
|
)
|
|
.map_err(PipeError::Security)?;
|
|
} else {
|
|
self.mac_policy
|
|
.validate(&action, expected_domain)
|
|
.map_err(PipeError::Security)?;
|
|
}
|
|
|
|
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) => {
|
|
let success = match action {
|
|
Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?,
|
|
Action::Type => {
|
|
self.execute_simulated_type(seq, expected_domain, ¶ms, &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 supports_live_input(&self) -> bool {
|
|
self.mac_policy.supports_pipe_action(&Action::Click)
|
|
&& self.mac_policy.supports_pipe_action(&Action::Type)
|
|
}
|
|
}
|
|
|
|
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 optional_string(params: &Value, key: &str) -> Option<String> {
|
|
params
|
|
.get(key)
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToString::to_string)
|
|
}
|
|
|
|
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}}})();\
|
|
function _s(v){{\
|
|
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(_){{}}\
|
|
}}\
|
|
if(v&&typeof v.then==='function'){{v.then(_s).catch(function(){{}});}}else{{_s(v);}}\
|
|
}}catch(e){{}}}})()"
|
|
)
|
|
}
|
|
|
|
fn build_input_probe_js(
|
|
mode: CallbackInputMode,
|
|
source_url: &str,
|
|
selector: Option<&str>,
|
|
probe_script: Option<&str>,
|
|
text: Option<&str>,
|
|
) -> Result<String, PipeError> {
|
|
let escaped_source_url = escape_js_single_quoted(source_url);
|
|
let callback = match mode {
|
|
CallbackInputMode::Click => CLICK_PROBE_CALLBACK_NAME,
|
|
CallbackInputMode::Type => TYPE_PROBE_CALLBACK_NAME,
|
|
};
|
|
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
|
let payload_expression = match mode {
|
|
CallbackInputMode::Click => "JSON.stringify({x:x,y:y})".to_string(),
|
|
CallbackInputMode::Type => {
|
|
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
|
format!("JSON.stringify({{x:x,y:y,text:'{escaped_text}'}})")
|
|
}
|
|
};
|
|
let payload_object = match mode {
|
|
CallbackInputMode::Click => "{x:x,y:y}".to_string(),
|
|
CallbackInputMode::Type => {
|
|
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
|
format!("{{x:x,y:y,text:'{escaped_text}'}}")
|
|
}
|
|
};
|
|
let element_lookup = if let Some(script) = probe_script {
|
|
format!("(function(){{{script}}})()")
|
|
} else if let Some(selector) = selector {
|
|
let escaped_selector = escape_js_single_quoted(selector);
|
|
format!("document.querySelector('{escaped_selector}')")
|
|
} else {
|
|
return Err(PipeError::Protocol(
|
|
"selector or probe_script is required".to_string(),
|
|
));
|
|
};
|
|
let missing_hint = selector
|
|
.map(|value| format!("selector not found: {}", escape_js_single_quoted(value)))
|
|
.unwrap_or_else(|| "input probe target not found".to_string());
|
|
|
|
Ok(format!(
|
|
"(function(){{try{{\
|
|
var el={element_lookup};\
|
|
if(!el){{throw new Error('{missing_hint}');}}\
|
|
var rect=(typeof el.getBoundingClientRect==='function')?el.getBoundingClientRect():null;\
|
|
var x=rect?(rect.left+(rect.width/2)):0;\
|
|
var y=rect?(rect.top+(rect.height/2)):0;\
|
|
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+String({payload_expression}))}}catch(_){{}}\
|
|
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{payload_object}}});\
|
|
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('\'', "\\'")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
.replace('\0', "\\0")
|
|
.replace('\u{2028}', "\\u2028")
|
|
.replace('\u{2029}', "\\u2029")
|
|
}
|
|
|
|
struct LocalDashboardRequest {
|
|
presentation_url: String,
|
|
output_path: String,
|
|
}
|
|
|
|
fn approved_local_dashboard_request(
|
|
action: &Action,
|
|
params: &Value,
|
|
expected_domain: &str,
|
|
) -> Option<LocalDashboardRequest> {
|
|
if action != &Action::Navigate || expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
|
|
return None;
|
|
}
|
|
|
|
let presentation_url = params.get("url")?.as_str()?.trim();
|
|
let marker = params.get("sgclaw_local_dashboard_open")?.as_object()?;
|
|
let source = marker.get("source")?.as_str()?.trim();
|
|
let kind = marker.get("kind")?.as_str()?.trim();
|
|
let output_path = marker.get("output_path")?.as_str()?.trim();
|
|
let marker_presentation_url = marker.get("presentation_url")?.as_str()?.trim();
|
|
|
|
if source != LOCAL_DASHBOARD_SOURCE
|
|
|| kind != LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN
|
|
|| output_path.is_empty()
|
|
|| presentation_url.is_empty()
|
|
|| marker_presentation_url != presentation_url
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(LocalDashboardRequest {
|
|
presentation_url: presentation_url.to_string(),
|
|
output_path: output_path.to_string(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::VecDeque;
|
|
|
|
fn test_policy() -> MacPolicy {
|
|
MacPolicy::from_json_str(
|
|
r#"{
|
|
"version": "1.0",
|
|
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
|
"pipe_actions": {
|
|
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
|
"blocked": []
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
struct FakeCallbackHost {
|
|
requests: Mutex<Vec<BrowserCallbackRequest>>,
|
|
replies: Mutex<VecDeque<Result<BrowserCallbackResponse, PipeError>>>,
|
|
}
|
|
|
|
impl FakeCallbackHost {
|
|
fn new(replies: Vec<Result<BrowserCallbackResponse, PipeError>>) -> Self {
|
|
Self {
|
|
requests: Mutex::new(Vec::new()),
|
|
replies: Mutex::new(VecDeque::from(replies)),
|
|
}
|
|
}
|
|
|
|
fn requests(&self) -> Vec<BrowserCallbackRequest> {
|
|
self.requests.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
impl BrowserCallbackHost for FakeCallbackHost {
|
|
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
|
|
self.requests.lock().unwrap().push(request);
|
|
self.replies
|
|
.lock()
|
|
.unwrap()
|
|
.pop_front()
|
|
.unwrap_or_else(|| Err(PipeError::Timeout))
|
|
}
|
|
}
|
|
|
|
fn success_reply(data: Value) -> Result<BrowserCallbackResponse, PipeError> {
|
|
Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
|
success: true,
|
|
data,
|
|
aom_snapshot: vec![],
|
|
timing: Timing {
|
|
queue_ms: 1,
|
|
exec_ms: 1,
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_click_treats_simulated_mouse_follow_up_as_fire_and_forget() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
|
json!({ "probe": { "x": 320.5, "y": 240.25 } }),
|
|
)]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Click,
|
|
json!({
|
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
|
"selector": "button"
|
|
}),
|
|
"zhuanlan.zhihu.com",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(requests[1].command, json!([
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
"sgBroewserSimulateMouse",
|
|
320.5,
|
|
240.25,
|
|
"left",
|
|
"",
|
|
""
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_click_survives_simulated_mouse_timeout() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
|
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
|
Err(PipeError::Timeout),
|
|
]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Click,
|
|
json!({
|
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
|
"selector": "button"
|
|
}),
|
|
"zhuanlan.zhihu.com",
|
|
)
|
|
.expect("simulated mouse timeout should be treated as fire-and-forget success");
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_click_uses_domain_probe_then_simulated_mouse_input() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
|
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
|
success_reply(json!({ "clicked": true })),
|
|
]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Click,
|
|
json!({
|
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
|
"selector": "button"
|
|
}),
|
|
"zhuanlan.zhihu.com",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(requests[0].action, "click");
|
|
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
|
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
|
let script = requests[0].command[3].as_str().unwrap();
|
|
assert!(script.contains("document.querySelector('button')"));
|
|
assert!(script.contains("sgclawOnClick"));
|
|
assert_eq!(requests[1].action, "click");
|
|
assert_eq!(requests[1].command, json!([
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
"sgBroewserSimulateMouse",
|
|
320.5,
|
|
240.25,
|
|
"left",
|
|
"",
|
|
""
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_type_treats_simulated_keyboard_follow_up_as_fire_and_forget() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
|
json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } }),
|
|
)]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Type,
|
|
json!({
|
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
|
"selector": "div[contenteditable='true']",
|
|
"text": "正文"
|
|
}),
|
|
"zhuanlan.zhihu.com",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(requests[1].command, json!([
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
"sgBroewserSimulateKeyborad",
|
|
160.0,
|
|
90.0,
|
|
"正文"
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_type_uses_custom_probe_script_when_provided() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
|
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
|
success_reply(json!({ "typed": true })),
|
|
]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Type,
|
|
json!({
|
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
|
"probe_script": "return document.body;",
|
|
"text": "正文"
|
|
}),
|
|
"zhuanlan.zhihu.com",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
let script = requests[0].command[3].as_str().unwrap();
|
|
assert!(script.contains("return document.body;"));
|
|
assert!(!script.contains("selector not found: div[contenteditable='true']"));
|
|
assert_eq!(requests[1].command, json!([
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
"sgBroewserSimulateKeyborad",
|
|
160.0,
|
|
90.0,
|
|
"正文"
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_type_uses_domain_probe_then_simulated_keyboard_input() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
|
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
|
success_reply(json!({ "typed": true })),
|
|
]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Type,
|
|
json!({
|
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
|
"selector": "div[contenteditable='true']",
|
|
"text": "正文"
|
|
}),
|
|
"zhuanlan.zhihu.com",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(requests[0].action, "type");
|
|
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
|
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
|
let script = requests[0].command[3].as_str().unwrap();
|
|
assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')"));
|
|
assert!(script.contains("sgclawOnType"));
|
|
assert!(!script.contains("el.value="));
|
|
assert_eq!(requests[1].action, "type");
|
|
assert_eq!(requests[1].command, json!([
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
"sgBroewserSimulateKeyborad",
|
|
160.0,
|
|
90.0,
|
|
"正文"
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_accepts_approved_local_dashboard_navigate_request() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({
|
|
"navigated": true
|
|
}))]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let output = backend
|
|
.invoke(
|
|
Action::Navigate,
|
|
json!({
|
|
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
|
"sgclaw_local_dashboard_open": {
|
|
"source": "compat.workflow_executor",
|
|
"kind": "zhihu_hotlist_screen",
|
|
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
|
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
|
}
|
|
}),
|
|
"__sgclaw_local_dashboard__",
|
|
)
|
|
.expect("approved local dashboard request should be accepted");
|
|
|
|
assert!(output.success);
|
|
let requests = host.requests();
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(requests[0].command, json!([
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
"sgBrowerserOpenPage",
|
|
"file:///C:/tmp/zhihu-hotlist-screen.html"
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() {
|
|
let host = Arc::new(FakeCallbackHost::new(vec![]));
|
|
let backend = BrowserCallbackBackend::new(
|
|
host.clone(),
|
|
test_policy(),
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
);
|
|
|
|
let err = backend
|
|
.invoke(
|
|
Action::Navigate,
|
|
json!({
|
|
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
|
"sgclaw_local_dashboard_open": {
|
|
"source": "compat.workflow_executor",
|
|
"kind": "zhihu_hotlist_screen",
|
|
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
|
}
|
|
}),
|
|
"__sgclaw_local_dashboard__",
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert!(host.requests().is_empty());
|
|
assert!(err.to_string().contains("domain is not allowed"));
|
|
}
|
|
|
|
#[test]
|
|
fn escape_js_single_quoted_escapes_newlines_and_control_chars() {
|
|
let raw = "第一行\n第二行\r\n第三行";
|
|
let escaped = escape_js_single_quoted(raw);
|
|
assert!(!escaped.contains('\n'), "literal newline must be escaped");
|
|
assert!(!escaped.contains('\r'), "literal carriage return must be escaped");
|
|
assert!(escaped.contains("\\n"), "should contain escaped newline");
|
|
assert!(escaped.contains("\\r"), "should contain escaped carriage return");
|
|
assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行");
|
|
}
|
|
|
|
#[test]
|
|
fn type_probe_script_with_multiline_text_is_valid_js() {
|
|
let text_with_newlines = "标题\n\n正文第一段\n正文第二段";
|
|
let js = build_input_probe_js(
|
|
CallbackInputMode::Type,
|
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
|
Some("div[contenteditable='true']"),
|
|
None,
|
|
Some(text_with_newlines),
|
|
)
|
|
.unwrap();
|
|
// The generated JS must NOT contain literal newlines inside single-quoted strings.
|
|
// Split on single quotes and check inner segments.
|
|
assert!(
|
|
!js.contains("标题\n"),
|
|
"literal newline must not appear in the JS probe script"
|
|
);
|
|
assert!(js.contains("标题\\n"));
|
|
assert!(js.contains("sgclawOnTypeProbe"));
|
|
}
|
|
}
|