Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs
T
kdletters bdf7ed288a 放宽直连游戏验收并增加运行时素材证据
- 将固定四切片与固定 drawImage 降为推荐路径

- 在导航前注入 Canvas/WebGL 平台素材运行时观测

- 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改

- 补充直连验收、浏览器脚本与真实 Chrome fixture 测试

- 同步 AGC 直连 Runtime 实施计划
2026-08-18 13:14:45 +08:00

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()
}