bdf7ed288a
- 将固定四切片与固定 drawImage 降为推荐路径 - 在导航前注入 Canvas/WebGL 平台素材运行时观测 - 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改 - 补充直连验收、浏览器脚本与真实 Chrome fixture 测试 - 同步 AGC 直连 Runtime 实施计划
777 lines
29 KiB
Rust
777 lines
29 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use chromiumoxide::cdp::browser_protocol::fetch::{
|
|
ContinueRequestParams, EnableParams as FetchEnableParams, EventRequestPaused,
|
|
FailRequestParams, RequestPattern, RequestStage,
|
|
};
|
|
use chromiumoxide::cdp::browser_protocol::network::{
|
|
ErrorReason, EventLoadingFailed, EventRequestWillBeSent, EventResponseReceived,
|
|
EventWebSocketCreated, EventWebSocketFrameError, EventWebSocketWillSendHandshakeRequest,
|
|
ResourceType,
|
|
};
|
|
use chromiumoxide::cdp::browser_protocol::page::{
|
|
EventJavascriptDialogOpening, HandleJavaScriptDialogParams,
|
|
};
|
|
use chromiumoxide::cdp::js_protocol::runtime::{
|
|
ConsoleApiCalledType, EventConsoleApiCalled, EventExceptionThrown, RemoteObject,
|
|
};
|
|
use chromiumoxide::Page;
|
|
use futures::StreamExt;
|
|
use serde::Deserialize;
|
|
use tokio::task::JoinHandle;
|
|
use url::Url;
|
|
|
|
use super::model::{
|
|
BrowserCanvasProbe, BrowserConsoleMessage, BrowserException, BrowserFailedRequest,
|
|
MAX_URL_CHARS,
|
|
};
|
|
use super::network_policy::{preview_request_decision, PreviewRequestDecision};
|
|
|
|
const MAX_VISIBLE_TEXT_CHARS: usize = 4_000;
|
|
const MAX_EVENT_TEXT_CHARS: usize = 2_000;
|
|
const MAX_CAPTURED_EVENTS: usize = 100;
|
|
const MAX_TRACKED_REQUESTS: usize = 2_048;
|
|
|
|
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(super) struct BrowserCanvasSnapshot {
|
|
pub(super) width: u32,
|
|
pub(super) height: u32,
|
|
pub(super) css_width: f64,
|
|
pub(super) css_height: f64,
|
|
pub(super) visible_area: f64,
|
|
pub(super) sample_count: u32,
|
|
pub(super) non_empty_pixel_count: u32,
|
|
pub(super) distinct_pixel_state_count: u32,
|
|
pub(super) probe_error: Option<String>,
|
|
}
|
|
|
|
impl BrowserCanvasSnapshot {
|
|
pub(super) fn into_evidence(self) -> BrowserCanvasProbe {
|
|
let non_empty = if self.probe_error.is_some() {
|
|
None
|
|
} else {
|
|
classify_canvas_pixel_probe(
|
|
self.sample_count,
|
|
self.non_empty_pixel_count,
|
|
self.distinct_pixel_state_count,
|
|
)
|
|
};
|
|
BrowserCanvasProbe {
|
|
width: self.width,
|
|
height: self.height,
|
|
css_width: self.css_width,
|
|
css_height: self.css_height,
|
|
visible_area: self.visible_area,
|
|
sample_count: self.sample_count,
|
|
non_empty_pixel_count: self.non_empty_pixel_count,
|
|
non_empty,
|
|
probe_error: self.probe_error,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn classify_canvas_pixel_probe(
|
|
sample_count: u32,
|
|
non_empty_pixel_count: u32,
|
|
distinct_pixel_state_count: u32,
|
|
) -> Option<bool> {
|
|
if sample_count == 0 {
|
|
return None;
|
|
}
|
|
Some(
|
|
non_empty_pixel_count > 0
|
|
&& non_empty_pixel_count <= sample_count
|
|
&& distinct_pixel_state_count >= 2
|
|
&& distinct_pixel_state_count <= sample_count,
|
|
)
|
|
}
|
|
|
|
pub(super) fn canvas_validation_diagnostic(
|
|
canvases: &[BrowserCanvasProbe],
|
|
) -> Option<&'static str> {
|
|
let mut visible_canvases = canvases.iter().filter(|canvas| canvas.visible_area > 0.0);
|
|
let Some(first_visible) = visible_canvases.next() else {
|
|
return Some("未发现可见 canvas");
|
|
};
|
|
if first_visible.non_empty == Some(true)
|
|
|| visible_canvases.any(|canvas| canvas.non_empty == Some(true))
|
|
{
|
|
None
|
|
} else {
|
|
Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态")
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct RequestInfo {
|
|
url: String,
|
|
method: String,
|
|
resource_type: String,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(super) struct CaptureState {
|
|
requests: HashMap<String, RequestInfo>,
|
|
pub(super) console_errors: Vec<BrowserConsoleMessage>,
|
|
pub(super) console_warnings: Vec<BrowserConsoleMessage>,
|
|
pub(super) exceptions: Vec<BrowserException>,
|
|
pub(super) failed_requests: Vec<BrowserFailedRequest>,
|
|
pub(super) blocked_dialog_count: u32,
|
|
pub(super) infrastructure_errors: Vec<String>,
|
|
}
|
|
|
|
impl CaptureState {
|
|
fn push_failed_request(&mut self, request: BrowserFailedRequest) {
|
|
if self.failed_requests.len() >= MAX_CAPTURED_EVENTS {
|
|
return;
|
|
}
|
|
if !self.failed_requests.iter().any(|existing| {
|
|
existing.url == request.url
|
|
&& existing.method == request.method
|
|
&& existing.error_text == request.error_text
|
|
&& existing.status_code == request.status_code
|
|
}) {
|
|
self.failed_requests.push(request);
|
|
}
|
|
}
|
|
|
|
fn push_infrastructure_error(&mut self, error: String) {
|
|
if self.infrastructure_errors.len() < 8 {
|
|
self.infrastructure_errors
|
|
.push(truncate_chars(&error, MAX_EVENT_TEXT_CHARS));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(super) struct PageSnapshot {
|
|
pub(super) final_url: String,
|
|
pub(super) title: String,
|
|
pub(super) ready_state: String,
|
|
pub(super) visible_text_summary: String,
|
|
pub(super) visible_text_character_count: usize,
|
|
pub(super) dom_character_count: usize,
|
|
pub(super) expected_text_matches: Vec<bool>,
|
|
pub(super) canvases: Vec<BrowserCanvasSnapshot>,
|
|
pub(super) canvas_or_webgl_image_paths: Vec<String>,
|
|
pub(super) blocked_popup_count: u32,
|
|
pub(super) blocked_download_count: u32,
|
|
pub(super) blocked_permission_count: u32,
|
|
pub(super) blocked_service_worker_count: u32,
|
|
}
|
|
|
|
pub(super) struct CaptureTasks {
|
|
state: Arc<Mutex<CaptureState>>,
|
|
handles: Vec<JoinHandle<()>>,
|
|
}
|
|
|
|
impl CaptureTasks {
|
|
pub(super) async fn stop(self) -> Result<CaptureState, String> {
|
|
for handle in &self.handles {
|
|
handle.abort();
|
|
}
|
|
for handle in self.handles {
|
|
let _ = handle.await;
|
|
}
|
|
Arc::try_unwrap(self.state)
|
|
.map_err(|_| "browser capture state is still in use".to_string())?
|
|
.into_inner()
|
|
.map_err(|_| "browser capture state lock is poisoned".to_string())
|
|
}
|
|
}
|
|
pub(super) fn preview_fetch_enable_params() -> FetchEnableParams {
|
|
FetchEnableParams::builder()
|
|
.pattern(
|
|
RequestPattern::builder()
|
|
.url_pattern("*")
|
|
.request_stage(RequestStage::Request)
|
|
.build(),
|
|
)
|
|
.build()
|
|
}
|
|
|
|
pub(super) async fn start_capture_tasks(
|
|
page: &Page,
|
|
preview_url: &Url,
|
|
) -> Result<CaptureTasks, String> {
|
|
let mut paused = page
|
|
.event_listener::<EventRequestPaused>()
|
|
.await
|
|
.map_err(|error| format!("监听请求拦截失败:{error}"))?;
|
|
page.execute(preview_fetch_enable_params())
|
|
.await
|
|
.map_err(|error| format!("启用请求阶段安全拦截失败:{error}"))?;
|
|
let mut request_events = page
|
|
.event_listener::<EventRequestWillBeSent>()
|
|
.await
|
|
.map_err(|error| format!("监听网络请求失败:{error}"))?;
|
|
let mut loading_failed = page
|
|
.event_listener::<EventLoadingFailed>()
|
|
.await
|
|
.map_err(|error| format!("监听失败请求失败:{error}"))?;
|
|
let mut responses = page
|
|
.event_listener::<EventResponseReceived>()
|
|
.await
|
|
.map_err(|error| format!("监听 HTTP 响应失败:{error}"))?;
|
|
let mut websockets = page
|
|
.event_listener::<EventWebSocketCreated>()
|
|
.await
|
|
.map_err(|error| format!("监听 WebSocket 失败:{error}"))?;
|
|
let mut websocket_handshakes = page
|
|
.event_listener::<EventWebSocketWillSendHandshakeRequest>()
|
|
.await
|
|
.map_err(|error| format!("监听 WebSocket 握手失败:{error}"))?;
|
|
let mut websocket_errors = page
|
|
.event_listener::<EventWebSocketFrameError>()
|
|
.await
|
|
.map_err(|error| format!("监听 WebSocket 错误失败:{error}"))?;
|
|
let mut console = page
|
|
.event_listener::<EventConsoleApiCalled>()
|
|
.await
|
|
.map_err(|error| format!("监听 console 失败:{error}"))?;
|
|
let mut exceptions = page
|
|
.event_listener::<EventExceptionThrown>()
|
|
.await
|
|
.map_err(|error| format!("监听异常失败:{error}"))?;
|
|
let mut dialogs = page
|
|
.event_listener::<EventJavascriptDialogOpening>()
|
|
.await
|
|
.map_err(|error| format!("监听弹窗失败:{error}"))?;
|
|
|
|
let state = Arc::new(Mutex::new(CaptureState::default()));
|
|
let mut handles = Vec::new();
|
|
|
|
let task_page = page.clone();
|
|
let task_state = state.clone();
|
|
let origin = preview_url.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = paused.next().await {
|
|
let decision = preview_request_decision(
|
|
&event.request.url,
|
|
&event.resource_type,
|
|
event.redirected_request_id.is_some(),
|
|
&origin,
|
|
);
|
|
if let PreviewRequestDecision::Block(reason) = decision {
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
capture.push_failed_request(BrowserFailedRequest {
|
|
url: sanitize_url(&event.request.url),
|
|
method: event.request.method.clone(),
|
|
resource_type: event.resource_type.as_ref().to_string(),
|
|
error_text: reason.message().to_string(),
|
|
status_code: None,
|
|
canceled: true,
|
|
blocked_by_policy: true,
|
|
fatal: true,
|
|
});
|
|
}
|
|
if let Err(error) = task_page
|
|
.execute(FailRequestParams::new(
|
|
event.request_id.clone(),
|
|
ErrorReason::BlockedByClient,
|
|
))
|
|
.await
|
|
{
|
|
record_capture_error(&task_state, format!("阻止跨 origin 请求失败:{error}"));
|
|
break;
|
|
}
|
|
} else if let Err(error) = task_page
|
|
.execute(ContinueRequestParams::new(event.request_id.clone()))
|
|
.await
|
|
{
|
|
record_capture_error(&task_state, format!("放行同 origin 请求失败:{error}"));
|
|
break;
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = request_events.next().await {
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
if capture.requests.len() < MAX_TRACKED_REQUESTS {
|
|
capture.requests.insert(
|
|
event.request_id.inner().clone(),
|
|
RequestInfo {
|
|
url: event.request.url.clone(),
|
|
method: event.request.method.clone(),
|
|
resource_type: event
|
|
.r#type
|
|
.as_ref()
|
|
.map(|value| value.as_ref().to_string())
|
|
.unwrap_or_else(|| "Other".to_string()),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = loading_failed.next().await {
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
let info = capture.requests.get(event.request_id.inner()).cloned();
|
|
let canceled = event.canceled.unwrap_or(false);
|
|
let error_text = truncate_chars(&event.error_text, MAX_EVENT_TEXT_CHARS);
|
|
let fatal = !(canceled && error_text.contains("ERR_ABORTED"));
|
|
capture.push_failed_request(BrowserFailedRequest {
|
|
url: sanitize_url(info.as_ref().map(|value| value.url.as_str()).unwrap_or("")),
|
|
method: info
|
|
.as_ref()
|
|
.map(|value| value.method.clone())
|
|
.unwrap_or_else(|| "GET".to_string()),
|
|
resource_type: info
|
|
.as_ref()
|
|
.map(|value| value.resource_type.clone())
|
|
.unwrap_or_else(|| event.r#type.as_ref().to_string()),
|
|
error_text,
|
|
status_code: None,
|
|
canceled,
|
|
blocked_by_policy: false,
|
|
fatal,
|
|
});
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = responses.next().await {
|
|
if event.response.status < 400 {
|
|
continue;
|
|
}
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
let info = capture.requests.get(event.request_id.inner()).cloned();
|
|
let status = u16::try_from(event.response.status).unwrap_or(u16::MAX);
|
|
let url = event.response.url.clone();
|
|
let favicon_404 = status == 404
|
|
&& Url::parse(&url)
|
|
.ok()
|
|
.map(|value| value.path() == "/favicon.ico")
|
|
.unwrap_or(false);
|
|
capture.push_failed_request(BrowserFailedRequest {
|
|
url: sanitize_url(&url),
|
|
method: info
|
|
.as_ref()
|
|
.map(|value| value.method.clone())
|
|
.unwrap_or_else(|| "GET".to_string()),
|
|
resource_type: event.r#type.as_ref().to_string(),
|
|
error_text: format!("HTTP {status}"),
|
|
status_code: Some(status),
|
|
canceled: false,
|
|
blocked_by_policy: false,
|
|
fatal: !favicon_404,
|
|
});
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
let origin = preview_url.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
biased;
|
|
event = websockets.next() => {
|
|
let Some(event) = event else {
|
|
break;
|
|
};
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
capture.requests.insert(
|
|
event.request_id.inner().clone(),
|
|
RequestInfo {
|
|
url: event.url.clone(),
|
|
method: "GET".to_string(),
|
|
resource_type: "WebSocket".to_string(),
|
|
},
|
|
);
|
|
if let PreviewRequestDecision::Block(reason) = preview_request_decision(
|
|
&event.url,
|
|
&ResourceType::WebSocket,
|
|
false,
|
|
&origin,
|
|
) {
|
|
capture.push_failed_request(BrowserFailedRequest {
|
|
url: sanitize_url(&event.url),
|
|
method: "GET".to_string(),
|
|
resource_type: "WebSocket".to_string(),
|
|
error_text: reason.message().to_string(),
|
|
status_code: None,
|
|
canceled: true,
|
|
blocked_by_policy: true,
|
|
fatal: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
event = websocket_handshakes.next() => {
|
|
let Some(event) = event else {
|
|
break;
|
|
};
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
match capture.requests.get(event.request_id.inner()).cloned() {
|
|
Some(info)
|
|
if matches!(
|
|
preview_request_decision(
|
|
&info.url,
|
|
&ResourceType::WebSocket,
|
|
false,
|
|
&origin,
|
|
),
|
|
PreviewRequestDecision::Block(_)
|
|
) =>
|
|
{
|
|
capture.push_infrastructure_error(
|
|
"跨 origin WebSocket 已进入握手阶段".to_string(),
|
|
);
|
|
}
|
|
None => capture.push_infrastructure_error(
|
|
"无法核对 WebSocket 握手 origin".to_string(),
|
|
),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = websocket_errors.next().await {
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
let info = capture.requests.get(event.request_id.inner()).cloned();
|
|
capture.push_failed_request(BrowserFailedRequest {
|
|
url: sanitize_url(info.as_ref().map(|value| value.url.as_str()).unwrap_or("")),
|
|
method: "GET".to_string(),
|
|
resource_type: "WebSocket".to_string(),
|
|
error_text: truncate_chars(&event.error_message, MAX_EVENT_TEXT_CHARS),
|
|
status_code: None,
|
|
canceled: false,
|
|
blocked_by_policy: false,
|
|
fatal: true,
|
|
});
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = console.next().await {
|
|
let level = match event.r#type {
|
|
ConsoleApiCalledType::Error | ConsoleApiCalledType::Assert => "error",
|
|
ConsoleApiCalledType::Warning => "warning",
|
|
_ => continue,
|
|
};
|
|
let location = event
|
|
.stack_trace
|
|
.as_ref()
|
|
.and_then(|trace| trace.call_frames.first());
|
|
let message = BrowserConsoleMessage {
|
|
level: level.to_string(),
|
|
text: truncate_chars(
|
|
&event
|
|
.args
|
|
.iter()
|
|
.map(remote_object_text)
|
|
.collect::<Vec<_>>()
|
|
.join(" "),
|
|
MAX_EVENT_TEXT_CHARS,
|
|
),
|
|
source_url: location.map(|frame| sanitize_url(&frame.url)),
|
|
line_number: location.map(|frame| nonnegative_u32(frame.line_number)),
|
|
column_number: location.map(|frame| nonnegative_u32(frame.column_number)),
|
|
};
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
let target = if level == "error" {
|
|
&mut capture.console_errors
|
|
} else {
|
|
&mut capture.console_warnings
|
|
};
|
|
if target.len() < MAX_CAPTURED_EVENTS {
|
|
target.push(message);
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while let Some(event) = exceptions.next().await {
|
|
let details = &event.exception_details;
|
|
let text = details
|
|
.exception
|
|
.as_ref()
|
|
.and_then(|value| value.description.as_deref())
|
|
.unwrap_or(&details.text);
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
if capture.exceptions.len() < MAX_CAPTURED_EVENTS {
|
|
capture.exceptions.push(BrowserException {
|
|
text: truncate_chars(text, MAX_EVENT_TEXT_CHARS),
|
|
source_url: details.url.as_deref().map(sanitize_url),
|
|
line_number: nonnegative_u32(details.line_number),
|
|
column_number: nonnegative_u32(details.column_number),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
let task_page = page.clone();
|
|
let task_state = state.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
while dialogs.next().await.is_some() {
|
|
if let Ok(mut capture) = task_state.lock() {
|
|
capture.blocked_dialog_count = capture.blocked_dialog_count.saturating_add(1);
|
|
}
|
|
if let Err(error) = task_page
|
|
.execute(HandleJavaScriptDialogParams::new(false))
|
|
.await
|
|
{
|
|
record_capture_error(&task_state, format!("关闭 JavaScript 弹窗失败:{error}"));
|
|
break;
|
|
}
|
|
}
|
|
}));
|
|
|
|
Ok(CaptureTasks { state, handles })
|
|
}
|
|
|
|
fn record_capture_error(state: &Arc<Mutex<CaptureState>>, error: String) {
|
|
if let Ok(mut capture) = state.lock() {
|
|
capture.push_infrastructure_error(error);
|
|
}
|
|
}
|
|
pub(super) fn build_snapshot_script(expected_text: &[String]) -> Result<String, String> {
|
|
let expected = serde_json::to_string(expected_text)
|
|
.map_err(|error| format!("序列化 expectedText 失败:{error}"))?;
|
|
Ok(format!(
|
|
r#"(() => {{
|
|
const expected = {expected};
|
|
const text = String(document.body?.innerText || '').replace(/\s+/g, ' ').trim();
|
|
const security = window.__GENARRATIVE_PREVIEW_VALIDATION__ || {{}};
|
|
const canvases = Array.from(document.querySelectorAll('canvas')).slice(0, 32).map((canvas) => {{
|
|
const rect = canvas.getBoundingClientRect();
|
|
const style = getComputedStyle(canvas);
|
|
const visibleWidth = Math.max(0, Math.min(rect.right, innerWidth) - Math.max(rect.left, 0));
|
|
const visibleHeight = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0));
|
|
const visibleArea = style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0
|
|
? 0 : Math.round(visibleWidth * visibleHeight);
|
|
let sampleCount = 0;
|
|
let nonEmptyPixelCount = 0;
|
|
let distinctPixelStateCount = 0;
|
|
let probeError = null;
|
|
if (canvas.width > 0 && canvas.height > 0 && visibleArea > 0) {{
|
|
try {{
|
|
const probe = document.createElement('canvas');
|
|
probe.width = Math.min(64, canvas.width);
|
|
probe.height = Math.min(64, canvas.height);
|
|
const context = probe.getContext('2d', {{ willReadFrequently: true }});
|
|
context.drawImage(canvas, 0, 0, probe.width, probe.height);
|
|
const pixels = context.getImageData(0, 0, probe.width, probe.height).data;
|
|
sampleCount = pixels.length / 4;
|
|
let firstPixelState = null;
|
|
for (let index = 0; index < pixels.length; index += 4) {{
|
|
if (pixels[index + 3] !== 0) nonEmptyPixelCount += 1;
|
|
const pixelState = pixels[index] * 0x1000000
|
|
+ pixels[index + 1] * 0x10000
|
|
+ pixels[index + 2] * 0x100
|
|
+ pixels[index + 3];
|
|
if (firstPixelState === null) {{
|
|
firstPixelState = pixelState;
|
|
distinctPixelStateCount = 1;
|
|
}} else if (distinctPixelStateCount === 1 && pixelState !== firstPixelState) {{
|
|
distinctPixelStateCount = 2;
|
|
}}
|
|
}}
|
|
}} catch (error) {{
|
|
probeError = String(error).slice(0, 512);
|
|
}}
|
|
}}
|
|
return {{
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
cssWidth: rect.width,
|
|
cssHeight: rect.height,
|
|
visibleArea,
|
|
sampleCount,
|
|
nonEmptyPixelCount,
|
|
distinctPixelStateCount,
|
|
probeError
|
|
}};
|
|
}});
|
|
return {{
|
|
finalUrl: location.href,
|
|
title: document.title,
|
|
readyState: document.readyState,
|
|
visibleTextSummary: text.slice(0, {MAX_VISIBLE_TEXT_CHARS}),
|
|
visibleTextCharacterCount: text.length,
|
|
domCharacterCount: document.documentElement?.outerHTML?.length || 0,
|
|
expectedTextMatches: expected.map((value) => text.includes(value)),
|
|
canvases,
|
|
canvasOrWebglImagePaths: Array.from(new Set(security.canvasOrWebglImagePaths || [])).slice(0, 32),
|
|
blockedPopupCount: security.blockedPopupCount || 0,
|
|
blockedDownloadCount: security.blockedDownloadCount || 0,
|
|
blockedPermissionCount: security.blockedPermissionCount || 0,
|
|
blockedServiceWorkerCount: security.blockedServiceWorkerCount || 0
|
|
}};
|
|
}})()"#
|
|
))
|
|
}
|
|
|
|
pub(super) const RESTRICTION_SCRIPT: &str = r#"
|
|
(() => {
|
|
const state = {
|
|
blockedPopupCount: 0,
|
|
blockedDownloadCount: 0,
|
|
blockedPermissionCount: 0,
|
|
blockedServiceWorkerCount: 0
|
|
};
|
|
Object.defineProperty(window, '__GENARRATIVE_PREVIEW_VALIDATION__', {
|
|
value: state,
|
|
configurable: false,
|
|
enumerable: false,
|
|
writable: false
|
|
});
|
|
const blockPopup = () => { state.blockedPopupCount += 1; return null; };
|
|
try { Object.defineProperty(window, 'open', { value: blockPopup, configurable: false }); }
|
|
catch (_) { window.open = blockPopup; }
|
|
document.addEventListener('click', (event) => {
|
|
const anchor = event.target?.closest?.('a');
|
|
if (!anchor) return;
|
|
if (anchor.hasAttribute('download')) {
|
|
state.blockedDownloadCount += 1;
|
|
event.preventDefault();
|
|
}
|
|
if (anchor.target && anchor.target.toLowerCase() !== '_self') {
|
|
state.blockedPopupCount += 1;
|
|
event.preventDefault();
|
|
}
|
|
}, true);
|
|
document.addEventListener('submit', (event) => {
|
|
const target = event.target?.target;
|
|
if (target && target.toLowerCase() !== '_self') {
|
|
state.blockedPopupCount += 1;
|
|
event.preventDefault();
|
|
}
|
|
}, true);
|
|
const denied = () => {
|
|
state.blockedPermissionCount += 1;
|
|
return Promise.reject(new DOMException('Permission denied during preview validation', 'NotAllowedError'));
|
|
};
|
|
if (navigator.mediaDevices) {
|
|
try { navigator.mediaDevices.getUserMedia = denied; } catch (_) {}
|
|
try { navigator.mediaDevices.getDisplayMedia = denied; } catch (_) {}
|
|
}
|
|
if (navigator.clipboard) {
|
|
try { navigator.clipboard.read = denied; } catch (_) {}
|
|
try { navigator.clipboard.readText = denied; } catch (_) {}
|
|
try { navigator.clipboard.write = denied; } catch (_) {}
|
|
try { navigator.clipboard.writeText = denied; } catch (_) {}
|
|
}
|
|
if (navigator.geolocation) {
|
|
const geolocationDenied = (_success, failure) => {
|
|
state.blockedPermissionCount += 1;
|
|
if (failure) failure({ code: 1, message: 'Permission denied during preview validation' });
|
|
};
|
|
try { navigator.geolocation.getCurrentPosition = geolocationDenied; } catch (_) {}
|
|
try { navigator.geolocation.watchPosition = geolocationDenied; } catch (_) {}
|
|
}
|
|
if (window.Notification?.requestPermission) {
|
|
try {
|
|
Notification.requestPermission = () => {
|
|
state.blockedPermissionCount += 1;
|
|
return Promise.resolve('denied');
|
|
};
|
|
} catch (_) {}
|
|
}
|
|
if (navigator.serviceWorker) {
|
|
try {
|
|
const prototype = Object.getPrototypeOf(navigator.serviceWorker);
|
|
Object.defineProperty(prototype, 'register', {
|
|
value: () => {
|
|
state.blockedServiceWorkerCount += 1;
|
|
return Promise.reject(new DOMException('Service Worker disabled during preview validation', 'SecurityError'));
|
|
},
|
|
configurable: false
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
})();
|
|
"#;
|
|
|
|
pub(super) const RENDERED_IMAGE_CAPTURE_SCRIPT: &str = r#"
|
|
(() => {
|
|
const state = window.__GENARRATIVE_PREVIEW_VALIDATION__;
|
|
if (!state) return;
|
|
state.canvasOrWebglImagePaths = [];
|
|
const recordCanvasOrWebglImage = (candidate) => {
|
|
const source = candidate && (candidate.currentSrc || candidate.src);
|
|
if (typeof source !== 'string' || source.length === 0) return;
|
|
try {
|
|
const url = new URL(source, location.href);
|
|
if (url.origin !== location.origin || !url.pathname) return;
|
|
if (!state.canvasOrWebglImagePaths.includes(url.pathname) && state.canvasOrWebglImagePaths.length < 32) {
|
|
state.canvasOrWebglImagePaths.push(url.pathname);
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
const wrapImageRenderMethod = (prototype, method) => {
|
|
const original = prototype && prototype[method];
|
|
if (typeof original !== 'function') return;
|
|
try {
|
|
Object.defineProperty(prototype, method, {
|
|
value: function(...args) {
|
|
for (const candidate of args) recordCanvasOrWebglImage(candidate);
|
|
return original.apply(this, args);
|
|
},
|
|
configurable: true,
|
|
writable: true
|
|
});
|
|
} catch (_) {}
|
|
};
|
|
wrapImageRenderMethod(globalThis.CanvasRenderingContext2D?.prototype, 'drawImage');
|
|
for (const prototype of [
|
|
globalThis.WebGLRenderingContext?.prototype,
|
|
globalThis.WebGL2RenderingContext?.prototype
|
|
]) {
|
|
wrapImageRenderMethod(prototype, 'texImage2D');
|
|
wrapImageRenderMethod(prototype, 'texSubImage2D');
|
|
}
|
|
})();
|
|
"#;
|
|
|
|
fn remote_object_text(object: &RemoteObject) -> String {
|
|
if let Some(value) = &object.value {
|
|
if let Some(value) = value.as_str() {
|
|
return value.to_string();
|
|
}
|
|
return value.to_string();
|
|
}
|
|
object
|
|
.description
|
|
.clone()
|
|
.unwrap_or_else(|| object.r#type.as_ref().to_string())
|
|
}
|
|
|
|
pub(super) fn sanitize_url(raw: &str) -> String {
|
|
let Ok(mut url) = Url::parse(raw) else {
|
|
return truncate_chars(raw, MAX_URL_CHARS);
|
|
};
|
|
url.set_query(None);
|
|
url.set_fragment(None);
|
|
truncate_chars(url.as_str(), MAX_URL_CHARS)
|
|
}
|
|
|
|
pub(super) fn truncate_chars(value: &str, limit: usize) -> String {
|
|
value.chars().take(limit).collect()
|
|
}
|
|
|
|
fn nonnegative_u32(value: i64) -> u32 {
|
|
u32::try_from(value).unwrap_or_default()
|
|
}
|