1#![allow(dead_code)]
12#![allow(unused_imports)]
13use base64::Engine;
14use base64::engine::general_purpose::STANDARD;
15use clap::ValueEnum;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::error::Error;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::time::Duration;
24use sysinfo::{Pid, ProcessesToUpdate, System};
25use tokio::sync::Mutex;
26
27use crate::browser::cdp::{CdpClient, CdpEventWithParams};
28use crate::browser::chrome::ChromeProcess;
29use crate::browser::dom::{
30 AxNode, CompactAxNode, CompactInteractiveElement, DomNode, backend_node_reference,
31 find_interactive_elements, format_tree,
32};
33use crate::browser::mouse::{MouseEngine, Point};
34use crate::browser::policy::{BrowserPolicy, PolicyError};
35use crate::browser::profile::ProfileManager;
36
37pub type BrowserResult<T> = Result<T, Box<dyn Error>>;
41
42pub const MAX_BATCH_STEPS: usize = 32;
44
45pub const MAX_AUDIT_ENTRIES: usize = 512;
47
48#[derive(Debug, Clone, Serialize)]
50pub struct AuditEntry {
51 pub sequence: u64,
53 pub operation: String,
55 pub detail: String,
57 pub policy_preset: String,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
63#[serde(tag = "action", rename_all = "camelCase")]
64pub enum BatchStep {
65 #[serde(rename = "navigate")]
66 Navigate {
67 url: String,
68 #[serde(default = "default_timeout_ms")]
69 timeout_ms: u64,
70 },
71 #[serde(rename = "click")]
72 Click { target: String },
73 #[serde(rename = "type")]
74 Type {
75 text: String,
76 #[serde(default)]
77 target: Option<String>,
78 },
79 #[serde(rename = "check")]
80 Check { target: String },
81 #[serde(rename = "uncheck")]
82 Uncheck { target: String },
83 #[serde(rename = "select")]
84 Select { target: String, value: String },
85 #[serde(rename = "clear")]
86 Clear { target: String },
87 #[serde(rename = "scroll")]
88 Scroll {
89 #[serde(default)]
90 dx: f64,
91 #[serde(default)]
92 dy: f64,
93 },
94 #[serde(rename = "wait")]
95 Wait {
96 condition: String,
97 #[serde(default = "default_timeout_ms")]
98 timeout_ms: u64,
99 },
100 #[serde(rename = "observe")]
101 Observe {
102 #[serde(default)]
103 include_dom: bool,
104 #[serde(default)]
105 include_screenshot: bool,
106 #[serde(default)]
107 include_form_values: bool,
108 },
109 #[serde(rename = "screenshot")]
110 Screenshot,
111 #[serde(rename = "evaluate")]
112 Evaluate { expression: String },
113 #[serde(rename = "acceptDialog")]
114 AcceptDialog,
115 #[serde(rename = "dismissDialog")]
116 DismissDialog,
117}
118
119const fn default_timeout_ms() -> u64 {
120 20_000
121}
122
123#[derive(Debug, Clone, Serialize)]
125#[serde(tag = "status", rename_all = "snake_case")]
126pub enum BatchStepOutcome {
127 Success {
128 index: usize,
129 action: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 response_bytes: Option<usize>,
132 },
133 Error {
134 index: usize,
135 action: String,
136 message: String,
137 },
138}
139
140#[derive(Debug, Clone, Serialize)]
142pub struct BatchOutcome {
143 pub steps: Vec<BatchStepOutcome>,
144 pub completed: usize,
145 pub failed: usize,
146 pub total: usize,
147 pub success: bool,
148}
149
150#[derive(Debug, Clone, Serialize)]
152pub struct BatchPolicyDenial {
153 pub step_index: usize,
154 pub action: String,
155 pub reason: String,
156}
157
158pub const MAX_RECONCILE_REFS: usize = 16;
162pub const MAX_RECONCILE_HINTS: usize = 8;
163pub const MAX_RECONCILIATION_BYTES: usize = 8 * 1024;
164
165#[derive(Debug, Clone, Copy, Serialize)]
166#[serde(rename_all = "snake_case")]
167pub enum ReferenceMatch {
168 BackendNode,
169 RoleAndName,
170 AccessibleName,
171 Hint,
172 ScopedHint,
173}
174
175#[derive(Debug, Clone, Serialize)]
176#[serde(tag = "kind", rename_all = "snake_case")]
177pub enum ReferenceLostReason {
178 NotFound,
179 Ambiguous { candidates: Vec<CandidateSummary> },
180 StaleBoundary,
181 OutOfScope,
182}
183
184#[derive(Debug, Clone, Copy, Serialize)]
185#[serde(rename_all = "snake_case")]
186pub enum ReconciliationStatus {
187 Complete,
188 RouteChanged,
189}
190
191#[derive(Debug, Clone, Default)]
193pub struct ReconciliationOptions {
194 pub hints: Vec<Locator>,
195 pub scope_ref: Option<String>,
196}
197
198#[derive(Debug, Clone, Serialize)]
200#[serde(tag = "kind", rename_all = "snake_case")]
201pub enum ReferenceMapping {
202 Preserved { old: String, new: String },
204 Relocated {
206 old: String,
207 new: String,
208 #[serde(rename = "matchedBy")]
209 matched_by: ReferenceMatch,
210 },
211 Lost {
213 old: String,
214 reason: ReferenceLostReason,
215 },
216}
217
218#[derive(Debug, Clone, Serialize)]
220pub struct ReconciliationOutcome {
221 pub status: ReconciliationStatus,
222 #[serde(rename = "toRevision")]
223 pub to_revision: u64,
224 pub mappings: Vec<ReferenceMapping>,
225 pub preserved: usize,
226 pub relocated: usize,
227 pub lost: usize,
228 #[serde(rename = "mutationSummary")]
229 pub mutation_summary: MutationSummary,
230 pub incomplete: Vec<ObservationIncompleteReason>,
231}
232
233#[derive(Debug, Clone, Default, Serialize)]
234pub struct MutationSummary {
235 #[serde(rename = "urlChanged")]
236 pub url_changed: bool,
237 #[serde(rename = "titleChanged")]
238 pub title_changed: bool,
239 #[serde(rename = "revisionDelta")]
240 pub revision_delta: u64,
241 #[serde(rename = "softNavigationSuspected")]
242 pub soft_navigation_suspected: bool,
243}
244
245#[derive(Debug, Clone, Serialize)]
246pub struct DeltaControl {
247 pub reference: String,
248 pub role: String,
249 pub name: String,
250}
251
252#[derive(Debug, Clone, Serialize)]
253pub struct ObservationDelta {
254 #[serde(rename = "fromRevision")]
255 pub from_revision: u64,
256 #[serde(rename = "toRevision")]
257 pub to_revision: u64,
258 pub mutation_summary: MutationSummary,
259 pub added: Vec<DeltaControl>,
260 pub removed: Vec<DeltaControl>,
261 pub changed: Vec<DeltaControl>,
262 pub prior_incomplete: Vec<ObservationIncompleteReason>,
263 pub current_incomplete: Vec<ObservationIncompleteReason>,
264}
265
266#[derive(Debug, Clone, Serialize)]
268pub struct StaleReferenceRecovery {
269 pub suggestion: &'static str,
270 #[serde(rename = "fromRevision")]
271 pub from_revision: u64,
272 #[serde(rename = "staleRef")]
273 pub stale_ref: String,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct CheckpointV1 {
282 #[serde(rename = "schemaVersion")]
283 pub schema_version: u8,
284 #[serde(rename = "glassVersion")]
285 pub glass_version: String,
286 #[serde(rename = "exportedAt")]
287 pub exported_at: String,
288 pub profile: String,
289 #[serde(rename = "attachMode")]
290 pub attach_mode: bool,
291 pub topology: CheckpointTopology,
292 pub observation: CheckpointObservation,
293 pub policy: String,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct CheckpointTopology {
299 #[serde(rename = "targetId", skip_serializing_if = "Option::is_none")]
300 pub target_id: Option<String>,
301 #[serde(rename = "frameId", skip_serializing_if = "Option::is_none")]
302 pub frame_id: Option<String>,
303 pub url: String,
304 pub title: String,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct CheckpointObservation {
312 pub revision: u64,
313 #[serde(rename = "lastRefs")]
315 pub last_refs: Vec<String>,
316}
317
318#[derive(Debug, Clone, Serialize)]
320#[serde(tag = "kind", rename_all = "snake_case")]
321pub enum CheckpointError {
322 SchemaVersionMismatch { expected: u8, found: u8 },
323 TargetClosed,
324 Stale,
325 InvalidJson(String),
326}
327
328impl std::fmt::Display for CheckpointError {
329 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 match self {
331 Self::SchemaVersionMismatch { expected, found } => {
332 write!(
333 formatter,
334 "checkpoint schema version mismatch: expected {expected}, found {found}"
335 )
336 }
337 Self::TargetClosed => formatter.write_str("checkpoint target is no longer open"),
338 Self::Stale => formatter.write_str("checkpoint frame or route is stale"),
339 Self::InvalidJson(message) => write!(formatter, "invalid checkpoint JSON: {message}"),
340 }
341 }
342}
343
344impl Error for CheckpointError {}
345
346pub const COMPACT_TEXT_MAX_BYTES: usize = 16 * 1024;
348pub(crate) const TEXT_TRUNCATION_MARKER: &str = "\n[truncated]";
349pub(crate) const COMPACT_OBSERVATION_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
350pub(crate) const COMPACT_OBSERVATION_MAX_ATTEMPTS: u8 = 2;
351pub(crate) const COMPACT_PAGE_STATE_EXPRESSION: &str = r#"(() => {
352 const key = '__glassObservationRevision';
353 let state = globalThis[key];
354 if (!state) {
355 state = {revision: 0};
356 const observer = new MutationObserver(() => { state.revision += 1; });
357 observer.observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
358 globalThis[key] = state;
359 }
360 const summary = {scanned_elements:0, scan_limit:512, shadow_roots:0, child_frames:0, canvases:0, truncated:false};
361 const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT);
362 while (walker.nextNode()) {
363 if (summary.scanned_elements >= summary.scan_limit) { summary.truncated = true; break; }
364 const element = walker.currentNode;
365 summary.scanned_elements += 1;
366 if (element.shadowRoot) summary.shadow_roots += 1;
367 if (element.localName === 'iframe' || element.localName === 'frame') summary.child_frames += 1;
368 if (element.localName === 'canvas') summary.canvases += 1;
369 }
370 return {url:location.href, title:document.title, ready_state:document.readyState,
371 text:(() => { const source=document.body ? document.body.innerText : ''; const bytes=new Uint8Array(16384);
372 const encoded=new TextEncoder().encodeInto(source, bytes); summary.text_truncated=encoded.read < source.length;
373 return new TextDecoder().decode(bytes.subarray(0, encoded.written)); })(),
374 mutation_revision:state.revision, boundaries:summary};
375})()"#;
376pub(crate) const OWNED_BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(10);
377pub(crate) const AMBIGUOUS_CANDIDATE_LIMIT: usize = 8;
378pub(crate) const CANDIDATE_LABEL_MAX_BYTES: usize = 160;
379pub(crate) const TOPOLOGY_MAX_TARGETS: usize = 32;
380pub(crate) const TOPOLOGY_MAX_FRAMES: usize = 128;
381pub(crate) const TOPOLOGY_ID_MAX_BYTES: usize = 256;
382pub(crate) const TOPOLOGY_TEXT_MAX_BYTES: usize = 1024;
383pub(crate) const TOPOLOGY_MAX_EVENTS: usize = 64;
384pub(crate) const POPUP_WITNESS_LIFETIME_MS: u64 = 5_000;
385pub(crate) const POPUP_EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
386pub(crate) const POPUP_TOPOLOGY_QUIET_INTERVAL: Duration = Duration::from_millis(50);
387pub(crate) const POPUP_TOPOLOGY_POLL_INTERVAL: Duration = Duration::from_millis(5);
388pub(crate) const POPUP_VERIFY_CALL_TIMEOUT: Duration = Duration::from_secs(2);
389pub(crate) const POPUP_RELEASE_ACK_TIMEOUT: Duration = Duration::from_millis(500);
390pub(crate) const POPUP_ERROR_MESSAGE_MAX_BYTES: usize = 512;
391pub(crate) const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(50);
392pub(crate) const WAIT_LAST_STATE_MAX_BYTES: usize = 512;
393pub(crate) const NETWORK_IN_FLIGHT_LIMIT: usize = 1024;
394pub(crate) const MAX_WAIT_DEADLINE: Duration = Duration::from_secs(300);
395pub(crate) const MAX_WAIT_CONDITION_BYTES: usize = 4 * 1024;
396pub(crate) const MAX_DIAGNOSTIC_DURATION: Duration = Duration::from_secs(30);
397pub(crate) const MAX_DIAGNOSTIC_EVENTS: usize = 128;
398pub(crate) const MAX_DIAGNOSTIC_TEXT_BYTES: usize = 2 * 1024;
399pub(crate) const MAX_DIAGNOSTIC_URL_BYTES: usize = 4 * 1024;
400pub(crate) const MAX_VISUAL_PIXELS: f64 = 8.0 * 1024.0 * 1024.0;
403pub(crate) const MAX_VISUAL_BASE64_BYTES: usize = 64 * 1024 * 1024;
404pub(crate) const VISUAL_HEADER_BASE64_BYTES: usize = 64 * 1024;
405pub(crate) const HIT_TEST_FUNCTION: &str = r#"async function() {
406 let element = this && this.nodeType === Node.ELEMENT_NODE ? this : this && this.parentElement;
407 if (element) element = element.closest('button,a,input,select,textarea,[role],[tabindex]') || element;
408 if (!element || !element.isConnected) return {ok:false, reason:'detached'};
409 const sample = () => {
410 const rect = element.getBoundingClientRect();
411 return {left:rect.left, top:rect.top, width:rect.width, height:rect.height};
412 };
413 element.scrollIntoView({block:'nearest', inline:'nearest'});
414 const first = sample();
415 if (!element.isConnected) return {ok:false, reason:'detached'};
416 if (element.getAnimations({subtree:true}).some(animation => animation.playState === 'running'))
417 return {ok:false, reason:'unstable_geometry'};
418 const second = sample();
419 const style = getComputedStyle(element);
420 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 || second.width <= 0 || second.height <= 0)
421 return {ok:false, reason:'not_visible'};
422 if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true')
423 return {ok:false, reason:'disabled'};
424 if ([first.left, first.top, first.width, first.height].some((value, index) => Math.abs(value - [second.left, second.top, second.width, second.height][index]) > 1))
425 return {ok:false, reason:'unstable_geometry'};
426 const x = second.left + second.width / 2;
427 const y = second.top + second.height / 2;
428 if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight)
429 return {ok:false, reason:'outside_viewport'};
430 const hit = document.elementFromPoint(x, y);
431 if (!hit || (hit !== element && !element.contains(hit)))
432 return {ok:false, reason:'hit_test_blocked'};
433 return {ok:true, x, y};
434}"#;
435pub(crate) const PREFLIGHT_FUNCTION: &str = r#"function() {
439 let element = this && this.nodeType === Node.ELEMENT_NODE ? this : this && this.parentElement;
440 if (element) element = element.closest('button,a,input,select,textarea,[role],[tabindex]') || element;
441 if (!element || !element.isConnected) return {ok:false, reason:'detached'};
442 const rect = element.getBoundingClientRect();
443 const geometry = {
444 x: Math.round(rect.left * 10) / 10,
445 y: Math.round(rect.top * 10) / 10,
446 width: Math.round(rect.width * 10) / 10,
447 height: Math.round(rect.height * 10) / 10
448 };
449 const style = getComputedStyle(element);
450 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 || rect.width <= 0 || rect.height <= 0)
451 return {ok:false, reason:'not_visible', geometry};
452 if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true')
453 return {ok:false, reason:'disabled', geometry};
454 const x = rect.left + rect.width / 2;
455 const y = rect.top + rect.height / 2;
456 const inViewport = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight;
457 const hit = inViewport ? document.elementFromPoint(x, y) : null;
458 const hints = {
459 likelyNavigation: element.matches('a[href], [role="link"]'),
460 likelyPopup: element.matches('[target="_blank"], [rel~="noopener"], [aria-haspopup]'),
461 likelyFormSubmit: element.matches('button[type="submit"], input[type="submit"]')
462 };
463 if (!inViewport) return {ok:false, reason:'outside_viewport', geometry, hints};
464 if (!hit || (hit !== element && !element.contains(hit))) return {ok:false, reason:'hit_test_blocked', geometry, hints};
465 return {ok:true, geometry, hints};
466}"#;
467pub(crate) const WAIT_TARGET_STATE_FUNCTION: &str = r#"function() {
468 let element = this && this.nodeType === Node.ELEMENT_NODE ? this : this && this.parentElement;
469 if (!element || !element.isConnected) return {attached:false, visible:false, enabled:false};
470 const rect = element.getBoundingClientRect();
471 const style = getComputedStyle(element);
472 const visible = style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
473 const enabled = !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true';
474 return {attached:true, visible, enabled, geometry:[rect.left, rect.top, rect.width, rect.height].map(value => Math.round(value * 10) / 10).join(',')};
475}"#;
476
477pub(crate) fn is_false(value: &bool) -> bool {
478 !*value
479}
480
481#[derive(Debug, Clone)]
490pub struct SessionOptions {
491 #[doc(hidden)]
492 pub port: u16,
493 #[doc(hidden)]
494 pub chrome_path: Option<PathBuf>,
495 #[doc(hidden)]
496 pub profile: String,
497 #[doc(hidden)]
498 pub incognito: bool,
499 #[doc(hidden)]
501 pub attach: bool,
502 #[doc(hidden)]
505 pub target_id: Option<String>,
506 #[doc(hidden)]
507 pub frame_id: Option<String>,
508 #[doc(hidden)]
509 pub headed: bool,
510 #[doc(hidden)]
511 pub interaction_mode: InteractionMode,
512 #[doc(hidden)]
514 pub audit: bool,
515 #[doc(hidden)]
519 pub policy: Option<BrowserPolicy>,
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
527pub enum InteractionMode {
528 Human,
529 Fast,
530}
531
532impl Default for SessionOptions {
533 fn default() -> Self {
534 Self {
535 port: 9222,
536 chrome_path: None,
537 profile: "default".to_string(),
538 incognito: false,
539 attach: false,
540 target_id: None,
541 frame_id: None,
542 headed: false,
543 interaction_mode: InteractionMode::Human,
544 audit: false,
545 policy: None,
546 }
547 }
548}
549
550impl SessionOptions {
551 pub fn builder() -> SessionOptionsBuilder {
565 SessionOptionsBuilder::default()
566 }
567
568 pub fn validate(&self) -> BrowserResult<()> {
570 if self
571 .target_id
572 .as_deref()
573 .is_some_and(|target_id| target_id.trim().is_empty())
574 {
575 return Err("target ID cannot be empty".into());
576 }
577 if self
578 .frame_id
579 .as_deref()
580 .is_some_and(|frame_id| frame_id.trim().is_empty())
581 {
582 return Err("frame ID cannot be empty".into());
583 }
584
585 if self.attach {
586 if self.incognito {
587 return Err("--attach cannot be combined with --incognito".into());
588 }
589 if self.profile != "default" {
590 return Err(
591 "--attach cannot be combined with a named --profile; attached Chrome owns its profile"
592 .into(),
593 );
594 }
595 if self.chrome_path.is_some() {
596 return Err("--attach cannot be combined with --chrome-path".into());
597 }
598 if self.headed {
599 return Err("--attach cannot be combined with --headed".into());
600 }
601 } else {
602 ProfileManager::validate_name(&self.profile)?;
603 }
604 Ok(())
605 }
606}
607
608#[derive(Debug, Clone)]
617pub struct SessionOptionsBuilder {
618 port: u16,
619 chrome_path: Option<PathBuf>,
620 profile: String,
621 incognito: bool,
622 attach: bool,
623 target_id: Option<String>,
624 frame_id: Option<String>,
625 headed: bool,
626 interaction_mode: InteractionMode,
627 audit: bool,
628 policy: Option<BrowserPolicy>,
629}
630
631impl Default for SessionOptionsBuilder {
632 fn default() -> Self {
633 let defaults = SessionOptions::default();
634 Self {
635 port: defaults.port,
636 chrome_path: defaults.chrome_path,
637 profile: defaults.profile,
638 incognito: defaults.incognito,
639 attach: defaults.attach,
640 target_id: defaults.target_id,
641 frame_id: defaults.frame_id,
642 headed: defaults.headed,
643 interaction_mode: defaults.interaction_mode,
644 audit: defaults.audit,
645 policy: defaults.policy,
646 }
647 }
648}
649
650impl SessionOptionsBuilder {
651 pub fn port(mut self, port: u16) -> Self {
653 self.port = port;
654 self
655 }
656
657 pub fn chrome_path(mut self, chrome_path: impl Into<PathBuf>) -> Self {
659 self.chrome_path = Some(chrome_path.into());
660 self
661 }
662
663 pub fn profile(mut self, profile: impl Into<String>) -> Self {
665 self.profile = profile.into();
666 self
667 }
668
669 pub fn incognito(mut self, incognito: bool) -> Self {
671 self.incognito = incognito;
672 self
673 }
674
675 pub fn attach(mut self, attach: bool) -> Self {
677 self.attach = attach;
678 self
679 }
680
681 pub fn target_id(mut self, target_id: impl Into<String>) -> Self {
683 self.target_id = Some(target_id.into());
684 self
685 }
686
687 pub fn frame_id(mut self, frame_id: impl Into<String>) -> Self {
689 self.frame_id = Some(frame_id.into());
690 self
691 }
692
693 pub fn headed(mut self, headed: bool) -> Self {
695 self.headed = headed;
696 self
697 }
698
699 pub fn interaction_mode(mut self, mode: InteractionMode) -> Self {
701 self.interaction_mode = mode;
702 self
703 }
704
705 pub fn audit(mut self, audit: bool) -> Self {
707 self.audit = audit;
708 self
709 }
710
711 pub fn policy(mut self, policy: BrowserPolicy) -> Self {
717 self.policy = Some(policy);
718 self
719 }
720
721 pub fn policy_preset(
726 mut self,
727 preset: crate::browser::policy::PolicyPreset,
728 workspace_root: impl AsRef<Path>,
729 ) -> Result<Self, Box<dyn Error>> {
730 self.policy = Some(BrowserPolicy::from_preset(preset, workspace_root)?);
731 Ok(self)
732 }
733
734 pub fn build(self) -> BrowserResult<SessionOptions> {
738 let options = SessionOptions {
739 port: self.port,
740 chrome_path: self.chrome_path,
741 profile: self.profile,
742 incognito: self.incognito,
743 attach: self.attach,
744 target_id: self.target_id,
745 frame_id: self.frame_id,
746 headed: self.headed,
747 interaction_mode: self.interaction_mode,
748 audit: self.audit,
749 policy: self.policy,
750 };
751 options.validate()?;
752 Ok(options)
753 }
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize)]
758pub struct PageInfo {
759 pub url: String,
760 pub title: String,
761 #[serde(default)]
762 pub ready_state: String,
763 #[serde(default)]
764 pub target_id: String,
765 #[serde(default)]
766 pub frame_id: String,
767}
768
769#[derive(Debug, Clone, Serialize)]
771pub struct PageTargetInfo {
772 pub id: String,
773 pub url: String,
774 pub title: String,
775 #[serde(skip_serializing_if = "Option::is_none")]
776 pub opener_id: Option<String>,
777 pub active: bool,
778}
779
780#[derive(Debug, Clone, Serialize)]
782pub struct FrameInfo {
783 pub id: String,
784 #[serde(skip_serializing_if = "Option::is_none")]
785 pub parent_id: Option<String>,
786 pub url: String,
787 pub active: bool,
788 pub out_of_process: bool,
789}
790
791#[derive(Debug, Clone, Serialize)]
793pub struct TopologyEventSummary {
794 pub sequence: u64,
795 pub kind: String,
796 pub id: String,
797}
798
799#[derive(Debug, Clone)]
800pub(crate) struct DestroyedPageTarget {
801 pub(crate) target: PageTargetInfo,
802 pub(crate) observed_sequence: u64,
803}
804
805#[derive(Default)]
806pub(crate) struct TopologyRegistry {
807 pub(crate) targets: Vec<PageTargetInfo>,
808 pub(crate) frames: Vec<FrameInfo>,
809 pub(crate) active_target_id: Option<String>,
810 pub(crate) active_frame_id: Option<String>,
811 pub(crate) active_target_session_id: Option<String>,
812 pub(crate) active_session_id: Option<String>,
813 pub(crate) frame_sessions: HashMap<String, String>,
814 pub(crate) frame_parents: HashMap<String, String>,
815 pub(crate) events: VecDeque<TopologyEventSummary>,
816 pub(crate) sequence: u64,
817 pub(crate) event_loss_count: u64,
818 pub(crate) target_sequences: HashMap<String, u64>,
819 pub(crate) destroyed_targets: VecDeque<DestroyedPageTarget>,
820 pub(crate) pending_dialog: Option<PendingDialog>,
822}
823
824#[derive(Debug, Clone, Serialize)]
826pub struct PendingDialog {
827 #[serde(rename = "type")]
829 pub dialog_type: String,
830 pub message: String,
832 #[serde(skip_serializing_if = "Option::is_none")]
834 pub default_value: Option<String>,
835 pub url: String,
837}
838
839#[derive(Debug, Clone, Serialize)]
844pub struct InteractiveElement {
845 pub reference: String,
846 pub role: String,
847 pub name: String,
848 pub description: String,
849 pub backend_dom_node_id: i64,
850 pub input_type: Option<String>,
852}
853
854#[derive(Debug, Clone, PartialEq, Eq)]
856pub enum Locator {
857 Reference(String),
858 AccessibleName(String),
859 RoleAndName { role: String, name: String },
860 Text(String),
861 Css(String),
862 Ordinal(usize),
863}
864
865#[derive(Debug, Clone, Serialize)]
867pub struct CandidateSummary {
868 pub label: String,
869 #[serde(skip_serializing_if = "Option::is_none")]
870 pub reference: Option<String>,
871}
872
873#[derive(Debug, Clone, Serialize)]
875pub struct TargetError {
876 pub kind: TargetErrorKind,
877 #[serde(skip_serializing_if = "Option::is_none")]
878 pub reason: Option<TargetActionabilityReason>,
879 #[serde(skip_serializing_if = "Vec::is_empty")]
880 pub candidates: Vec<CandidateSummary>,
881 #[serde(skip_serializing_if = "Option::is_none")]
882 pub recovery: Option<StaleReferenceRecovery>,
883}
884#[derive(Debug, Clone, Serialize)]
886pub struct PreflightOutcome {
887 pub action: PreflightAction,
889 pub unique: bool,
891 #[serde(skip_serializing_if = "Option::is_none")]
893 pub element: Option<ResolvedElement>,
894 #[serde(skip_serializing_if = "Option::is_none")]
896 pub actionable: Option<bool>,
897 #[serde(skip_serializing_if = "Option::is_none")]
899 pub actionability_reason: Option<TargetActionabilityReason>,
900 #[serde(skip_serializing_if = "Vec::is_empty")]
902 pub candidates: Vec<CandidateSummary>,
903 #[serde(skip_serializing_if = "Option::is_none")]
905 pub error_kind: Option<TargetErrorKind>,
906 pub revision: u64,
908 #[serde(skip_serializing_if = "Option::is_none")]
910 pub geometry: Option<PreflightGeometry>,
911 pub hints: PreflightHints,
913 pub target_id: Option<String>,
914 pub frame_id: Option<String>,
915}
916
917#[derive(Debug, Clone, Copy, Serialize)]
918pub struct PreflightGeometry {
919 pub x: f64,
920 pub y: f64,
921 pub width: f64,
922 pub height: f64,
923}
924
925#[derive(Debug, Clone, Copy, Default, Serialize)]
926pub struct PreflightHints {
927 pub likely_navigation: bool,
928 pub likely_popup: bool,
929 pub likely_form_submit: bool,
930}
931
932#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, clap::ValueEnum)]
933#[serde(rename_all = "snake_case")]
934pub enum PreflightAction {
935 #[default]
936 Click,
937 Hover,
938 Type,
939 Check,
940 Select,
941}
942
943#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, clap::ValueEnum)]
945#[serde(rename_all = "kebab-case")]
946pub enum ObservationRanking {
947 #[default]
948 Relevance,
949 DocumentOrder,
950}
951
952#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
954#[serde(rename_all = "snake_case")]
955pub enum TargetActionabilityReason {
956 Detached,
957 NotVisible,
958 Disabled,
959 UnstableGeometry,
960 OutsideViewport,
961 HitTestBlocked,
962 GeometryChanged,
963 NodeUnavailable,
964 VerificationFailed,
965}
966
967#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
969#[serde(rename_all = "snake_case")]
970pub enum TargetErrorKind {
971 Ambiguous,
972 NotFound,
973 StaleReference,
974 NotActionable,
975}
976
977impl std::fmt::Display for TargetError {
978 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
979 let message = match self.kind {
980 TargetErrorKind::Ambiguous => "element target is ambiguous",
981 TargetErrorKind::NotFound => "element target was not found",
982 TargetErrorKind::StaleReference => "element reference is stale",
983 TargetErrorKind::NotActionable => "element target is not actionable",
984 };
985 formatter.write_str(message)
986 }
987}
988
989impl Error for TargetError {}
990
991#[derive(Debug, Clone, Serialize)]
997pub struct TopologyError {
998 pub kind: TopologyErrorKind,
999 pub message: String,
1000 pub recovery: TopologyRecoveryHint,
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1006#[serde(rename_all = "snake_case")]
1007pub enum TopologyErrorKind {
1008 NoTargetSelected,
1011 StaleTarget,
1013 StaleFrame,
1015 NoSuchFrame,
1017 NoPageSession,
1019 BudgetExceeded,
1021 RoutingLost,
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1027#[serde(rename_all = "snake_case")]
1028pub enum TopologyRecoveryHint {
1029 ListTargets,
1031 ReObserve,
1033 ListFrames,
1035 Reconnect,
1037}
1038
1039impl TopologyError {
1040 pub(crate) fn new(kind: TopologyErrorKind, message: impl Into<String>) -> Self {
1041 let recovery = match kind {
1042 TopologyErrorKind::NoTargetSelected => TopologyRecoveryHint::ListTargets,
1043 TopologyErrorKind::StaleTarget => TopologyRecoveryHint::ListTargets,
1044 TopologyErrorKind::StaleFrame => TopologyRecoveryHint::ListFrames,
1045 TopologyErrorKind::NoSuchFrame => TopologyRecoveryHint::ListFrames,
1046 TopologyErrorKind::NoPageSession => TopologyRecoveryHint::Reconnect,
1047 TopologyErrorKind::BudgetExceeded => TopologyRecoveryHint::ReObserve,
1048 TopologyErrorKind::RoutingLost => TopologyRecoveryHint::Reconnect,
1049 };
1050 Self {
1051 kind,
1052 message: message.into(),
1053 recovery,
1054 }
1055 }
1056}
1057
1058impl std::fmt::Display for TopologyError {
1059 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1060 write!(
1061 formatter,
1062 "topology {:?}: {} (recovery: {:?})",
1063 self.kind, self.message, self.recovery
1064 )
1065 }
1066}
1067
1068impl Error for TopologyError {}
1069
1070#[derive(Debug)]
1071pub(crate) enum TargetResolution {
1072 Unique(ResolvedElement),
1073 Ambiguous(Vec<CandidateSummary>),
1074 NotFound,
1075}
1076
1077#[derive(Debug, Clone, Serialize)]
1082pub struct AccessibilitySnapshot {
1083 pub page: PageInfo,
1084 pub roots: Vec<AxNode>,
1085 pub interactive: Vec<InteractiveElement>,
1086}
1087
1088#[derive(Debug, Clone, PartialEq, Eq)]
1094pub enum WaitCondition {
1095 Lifecycle(String),
1096 UrlExact(String),
1097 UrlPrefix(String),
1098 TargetAttached(String),
1099 TargetVisible(String),
1100 TargetHidden(String),
1101 TargetEnabled(String),
1102 TargetStable(String),
1103 Text(String),
1104 JavaScript(String),
1105 NetworkQuiet(Duration),
1106}
1107
1108#[derive(Debug, Clone, Serialize)]
1110pub struct WaitOutcome {
1111 pub condition: String,
1112 pub elapsed_ms: u64,
1113 pub last_state: String,
1114 pub target_id: String,
1115 pub frame_id: String,
1116}
1117
1118#[derive(Debug, Clone, Serialize)]
1120pub struct WaitTimeout {
1121 pub condition: String,
1122 pub deadline_ms: u64,
1123 pub last_state: String,
1124 pub reason: &'static str,
1125}
1126
1127impl std::fmt::Display for WaitTimeout {
1128 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1129 write!(formatter, "wait timed out for {}", self.condition)
1130 }
1131}
1132
1133impl Error for WaitTimeout {}
1134
1135#[derive(Debug, Clone, Serialize)]
1137pub struct CompactAccessibilitySnapshot {
1138 pub page: PageInfo,
1139 pub revision: u64,
1141 pub roots: Vec<CompactAxNode>,
1142 pub interactive: Vec<CompactInteractiveElement>,
1143 #[serde(skip_serializing_if = "is_false")]
1144 pub truncated: bool,
1145 #[serde(skip_serializing_if = "is_zero")]
1147 pub omitted_count: usize,
1148 #[serde(skip_serializing_if = "is_false")]
1150 pub ranking_applied: bool,
1151 #[serde(skip_serializing_if = "Option::is_none")]
1153 pub completeness: Option<ObservationCompleteness>,
1154}
1155
1156fn is_zero(value: &usize) -> bool {
1157 *value == 0
1158}
1159
1160#[derive(Debug, Clone, Serialize)]
1162pub struct ObservationCompleteness {
1163 pub score: f64,
1165 pub confidence: &'static str,
1167 pub suggest_escalation: EscalationSuggestion,
1169 pub signals: CompletenessSignals,
1171}
1172
1173#[derive(Debug, Clone, Serialize)]
1175pub struct CompletenessSignals {
1176 pub interactive_discovered: usize,
1177 pub interactive_returned: usize,
1178 pub shadow_hosts: usize,
1179 pub shadow_hosts_pierced: usize,
1180 pub canvases: usize,
1181 pub child_frames: usize,
1182 pub mutation_race: bool,
1183}
1184
1185#[derive(Debug, Clone, Serialize)]
1187#[serde(rename_all = "snake_case")]
1188pub enum EscalationSuggestion {
1189 None,
1190 #[serde(rename = "getDOM")]
1191 GetDom,
1192 Coordinate,
1193 SelectFrame,
1194 Reobserve,
1195}
1196
1197impl ObservationCompleteness {
1198 pub fn compute(
1199 interactive_discovered: usize,
1200 interactive_returned: usize,
1201 shadow_hosts: usize,
1202 shadow_hosts_pierced: usize,
1203 canvases: usize,
1204 child_frames: usize,
1205 mutation_race: bool,
1206 ) -> Self {
1207 let interactive_factor = if interactive_discovered > 0 {
1209 (interactive_returned as f64 / interactive_discovered as f64).min(1.0)
1210 } else {
1211 1.0
1212 };
1213
1214 let shadow_factor = if shadow_hosts == 0 || shadow_hosts_pierced >= shadow_hosts {
1216 1.0
1217 } else if shadow_hosts_pierced > 0 {
1218 0.7
1219 } else {
1220 0.5
1221 };
1222
1223 let frame_factor = if child_frames == 0 { 1.0 } else { 0.8 };
1225
1226 let consistency_factor = if mutation_race { 0.0 } else { 1.0 };
1228
1229 let score = (interactive_factor * shadow_factor * frame_factor * consistency_factor)
1230 .clamp(0.0, 1.0);
1231 let score = (score * 100.0).round() / 100.0; let has_shadow_boundary = shadow_hosts > 0 && shadow_hosts_pierced < shadow_hosts;
1234 let confidence = if mutation_race {
1235 "low"
1236 } else if has_shadow_boundary || child_frames > 0 {
1237 "medium"
1238 } else {
1239 "high"
1240 };
1241
1242 let suggest_escalation = if mutation_race {
1243 EscalationSuggestion::Reobserve
1244 } else if score >= 0.85 && !has_shadow_boundary {
1245 EscalationSuggestion::None
1246 } else if has_shadow_boundary || score < 0.6 {
1247 EscalationSuggestion::GetDom
1248 } else if canvases > 0 && interactive_returned < 8 {
1249 EscalationSuggestion::Coordinate
1250 } else if child_frames > 0 {
1251 EscalationSuggestion::SelectFrame
1252 } else {
1253 EscalationSuggestion::None
1254 };
1255
1256 Self {
1257 score,
1258 confidence,
1259 suggest_escalation,
1260 signals: CompletenessSignals {
1261 interactive_discovered,
1262 interactive_returned,
1263 shadow_hosts,
1264 shadow_hosts_pierced,
1265 canvases,
1266 child_frames,
1267 mutation_race,
1268 },
1269 }
1270 }
1271}
1272
1273#[derive(Debug, Clone, Serialize)]
1275pub struct PageContext {
1276 pub page: PageInfo,
1277 pub text: String,
1278 #[serde(skip_serializing_if = "Option::is_none")]
1280 pub dom: Option<DomNode>,
1281 pub accessibility: CompactAccessibilitySnapshot,
1282 pub consistency: ObservationConsistency,
1283 pub boundaries: ObservationBoundarySummary,
1284 #[serde(skip_serializing_if = "Vec::is_empty")]
1285 pub incomplete: Vec<ObservationIncompleteReason>,
1286 #[serde(skip_serializing_if = "Option::is_none")]
1288 pub screenshot: Option<String>,
1289}
1290
1291#[derive(Debug, Clone, Serialize)]
1293pub struct ObservationConsistency {
1294 pub consistent: bool,
1295 pub attempts: u8,
1296 pub start_revision: u64,
1297 pub end_revision: u64,
1298 pub start_mutation_revision: u64,
1299 pub end_mutation_revision: u64,
1300}
1301
1302#[derive(Debug, Clone, Serialize)]
1304pub struct DiagnosticReport {
1305 pub target_id: String,
1306 pub frame_id: String,
1307 pub duration_ms: u64,
1308 pub console: Vec<ConsoleEvidence>,
1309 pub network: Vec<NetworkEvidence>,
1310 pub dropped_events: u64,
1311}
1312
1313#[derive(Debug, Clone, Serialize)]
1315pub struct ConsoleEvidence {
1316 pub level: String,
1317 pub text: String,
1318}
1319
1320#[derive(Debug, Clone, Serialize)]
1322pub struct NetworkEvidence {
1323 pub request_id: String,
1324 pub method: String,
1325 pub url: String,
1326 #[serde(skip_serializing_if = "Option::is_none")]
1327 pub status: Option<u16>,
1328 #[serde(skip_serializing_if = "Option::is_none")]
1329 pub failure: Option<String>,
1330 #[serde(skip_serializing_if = "Vec::is_empty")]
1331 pub safe_header_names: Vec<String>,
1332 pub redirect_count: u16,
1333}
1334
1335#[derive(Debug, Clone, Serialize)]
1337pub struct DownloadOutcome {
1338 pub guid: String,
1339 pub suggested_filename: String,
1340 pub state: String,
1341 pub received_bytes: u64,
1342 pub total_bytes: u64,
1343 pub target_id: String,
1344 pub frame_id: String,
1345 #[serde(skip_serializing_if = "Option::is_none")]
1347 pub sha256: Option<String>,
1348}
1349
1350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1352#[serde(rename_all = "snake_case")]
1353pub enum DownloadErrorKind {
1354 AuthorizationFailed,
1355 RestorationFailed,
1356}
1357
1358#[derive(Debug, Clone, Serialize)]
1360pub struct DownloadError {
1361 pub kind: DownloadErrorKind,
1362 pub message: String,
1363}
1364
1365impl std::fmt::Display for DownloadError {
1366 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1367 write!(formatter, "download {:?}: {}", self.kind, self.message)
1368 }
1369}
1370
1371impl Error for DownloadError {}
1372
1373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
1375#[serde(rename_all = "snake_case")]
1376pub enum VisualFormat {
1377 Png,
1378 Jpeg,
1379 Webp,
1380}
1381
1382impl VisualFormat {
1383 pub fn as_cdp(self) -> &'static str {
1384 match self {
1385 Self::Png => "png",
1386 Self::Jpeg => "jpeg",
1387 Self::Webp => "webp",
1388 }
1389 }
1390}
1391
1392impl std::str::FromStr for VisualClip {
1393 type Err = String;
1394
1395 fn from_str(value: &str) -> Result<Self, Self::Err> {
1396 let values = value
1397 .split(',')
1398 .map(|part| {
1399 part.trim()
1400 .parse::<f64>()
1401 .map_err(|_| "clip must be x,y,width,height".to_string())
1402 })
1403 .collect::<Result<Vec<_>, _>>()?;
1404 if values.len() != 4 {
1405 return Err("clip must be x,y,width,height".to_string());
1406 }
1407 Ok(Self {
1408 x: values[0],
1409 y: values[1],
1410 width: values[2],
1411 height: values[3],
1412 })
1413 }
1414}
1415
1416#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1418pub struct VisualClip {
1419 pub x: f64,
1420 pub y: f64,
1421 pub width: f64,
1422 pub height: f64,
1423}
1424
1425#[derive(Debug, Clone)]
1427pub struct VisualCaptureOptions {
1428 pub format: VisualFormat,
1429 pub quality: Option<u8>,
1430 pub scale: f64,
1431 pub clip: Option<VisualClip>,
1432 pub full_page: bool,
1433 pub target: Option<String>,
1434}
1435
1436impl Default for VisualCaptureOptions {
1437 fn default() -> Self {
1438 Self {
1439 format: VisualFormat::Png,
1440 quality: None,
1441 scale: 1.0,
1442 clip: None,
1443 full_page: false,
1444 target: None,
1445 }
1446 }
1447}
1448
1449#[derive(Debug, Clone, Serialize)]
1451pub struct VisualCaptureMetadata {
1452 pub format: VisualFormat,
1453 pub width: usize,
1454 pub height: usize,
1455 pub encoded_bytes: usize,
1456 pub device_scale_factor: f64,
1457 pub scale: f64,
1458 pub full_page: bool,
1459 #[serde(skip_serializing_if = "Option::is_none")]
1460 pub clip: Option<VisualClip>,
1461 pub target_id: String,
1462 pub frame_id: String,
1463}
1464
1465#[derive(Debug)]
1467pub struct VisualCapture {
1468 pub data: String,
1469 pub metadata: VisualCaptureMetadata,
1470}
1471
1472#[cfg(feature = "visual-compare")]
1476#[derive(Debug, Serialize)]
1477pub struct VisualComparison {
1478 pub width: u32,
1479 pub height: u32,
1480 pub changed_pixels: u64,
1481 pub changed_ratio: f64,
1482 #[serde(skip_serializing_if = "Option::is_none")]
1483 pub difference_box: Option<VisualClip>,
1484}
1485
1486#[cfg(feature = "visual-compare")]
1492pub fn compare_png_visuals(first: &str, second: &str) -> BrowserResult<VisualComparison> {
1493 let first = decode_png_for_comparison(first)?;
1494 let second = decode_png_for_comparison(second)?;
1495 if first.0 != second.0 || first.1 != second.1 || first.2 != second.2 {
1496 return Err("visual comparison requires equal PNG dimensions and color layout".into());
1497 }
1498 let (width, height, samples, first_pixels) = first;
1499 let second_pixels = second.3;
1500 let mut changed = 0_u64;
1501 let mut left = width;
1502 let mut top = height;
1503 let mut right = 0_u32;
1504 let mut bottom = 0_u32;
1505 for (index, (a, b)) in first_pixels
1506 .chunks_exact(samples)
1507 .zip(second_pixels.chunks_exact(samples))
1508 .enumerate()
1509 {
1510 if a != b {
1511 changed += 1;
1512 let x = index as u32 % width;
1513 let y = index as u32 / width;
1514 left = left.min(x);
1515 top = top.min(y);
1516 right = right.max(x);
1517 bottom = bottom.max(y);
1518 }
1519 }
1520 Ok(VisualComparison {
1521 width,
1522 height,
1523 changed_pixels: changed,
1524 changed_ratio: changed as f64 / (u64::from(width) * u64::from(height)) as f64,
1525 difference_box: (changed > 0).then_some(VisualClip {
1526 x: f64::from(left),
1527 y: f64::from(top),
1528 width: f64::from(right - left + 1),
1529 height: f64::from(bottom - top + 1),
1530 }),
1531 })
1532}
1533
1534#[cfg(feature = "visual-compare")]
1535pub(crate) fn decode_png_for_comparison(value: &str) -> BrowserResult<(u32, u32, usize, Vec<u8>)> {
1536 if value.len() > MAX_VISUAL_BASE64_BYTES {
1537 return Err("comparison PNG exceeded 64 MiB base64 budget".into());
1538 }
1539 let encoded = STANDARD.decode(value.as_bytes())?;
1540 let mut decoder = png::Decoder::new(std::io::Cursor::new(encoded));
1541 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
1542 let mut reader = decoder.read_info()?;
1543 let output_size = reader.output_buffer_size();
1544 if output_size > 32 * 1024 * 1024 {
1545 return Err("comparison PNG decoded size exceeded 32 MiB".into());
1546 }
1547 let mut pixels = vec![0; output_size];
1548 let info = reader.next_frame(&mut pixels)?;
1549 pixels.truncate(info.buffer_size());
1550 let samples = info.color_type.samples();
1551 Ok((info.width, info.height, samples, pixels))
1552}
1553
1554#[derive(Debug, Serialize)]
1556pub struct ScreencastFrame {
1557 pub data: String,
1558 pub metadata: Value,
1559}
1560
1561#[derive(Debug, Clone, Copy, Serialize)]
1563pub struct ScreencastStats {
1564 pub received: u64,
1565 pub dropped: u64,
1566}
1567
1568pub struct ScreencastScope {
1574 pub(crate) cdp: CdpClient,
1575 pub(crate) session_id: Option<String>,
1576 pub(crate) receiver: tokio::sync::mpsc::Receiver<crate::browser::cdp::CdpScreencastFrame>,
1577 pub(crate) armed: bool,
1578}
1579
1580pub(crate) struct ScreencastStartupGuard {
1581 pub(crate) cdp: CdpClient,
1582 pub(crate) session_id: Option<String>,
1583 pub(crate) armed: bool,
1584}
1585
1586impl ScreencastStartupGuard {
1587 pub(crate) fn disarm(&mut self) {
1588 self.armed = false;
1589 }
1590}
1591
1592impl Drop for ScreencastStartupGuard {
1593 fn drop(&mut self) {
1594 if !self.armed {
1595 return;
1596 }
1597 self.cdp.close_screencast_channel();
1598 let cdp = self.cdp.clone();
1599 let session_id = self.session_id.clone();
1600 tokio::spawn(async move {
1601 let _ = stop_screencast_for(&cdp, session_id.as_deref()).await;
1602 });
1603 }
1604}
1605
1606impl ScreencastScope {
1607 pub async fn next_frame(&mut self) -> Option<ScreencastFrame> {
1611 while let Some(frame) = self.receiver.recv().await {
1612 if frame.session_id == self.session_id {
1613 return Some(ScreencastFrame {
1614 data: frame.data,
1615 metadata: frame.metadata,
1616 });
1617 }
1618 }
1619 None
1620 }
1621
1622 pub fn stats(&self) -> ScreencastStats {
1624 let (received, dropped) = self.cdp.screencast_stats();
1625 ScreencastStats { received, dropped }
1626 }
1627
1628 pub async fn stop(mut self) -> BrowserResult<ScreencastStats> {
1630 stop_screencast_for(&self.cdp, self.session_id.as_deref()).await?;
1631 let (received, dropped) = self.cdp.close_screencast_channel();
1632 self.armed = false;
1633 Ok(ScreencastStats { received, dropped })
1634 }
1635}
1636
1637impl Drop for ScreencastScope {
1638 fn drop(&mut self) {
1639 if !self.armed {
1640 return;
1641 }
1642 self.cdp.close_screencast_channel();
1643 let cdp = self.cdp.clone();
1644 let session_id = self.session_id.clone();
1645 tokio::spawn(async move {
1646 let _ = stop_screencast_for(&cdp, session_id.as_deref()).await;
1647 });
1648 }
1649}
1650
1651#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1652pub struct ObservationBoundarySummary {
1653 pub scanned_elements: usize,
1654 pub scan_limit: usize,
1655 pub shadow_roots: usize,
1656 pub child_frames: usize,
1657 pub canvases: usize,
1658 pub truncated: bool,
1659 #[serde(default)]
1660 pub text_truncated: bool,
1661}
1662
1663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1664#[serde(rename_all = "snake_case")]
1665pub enum ObservationIncompleteReason {
1666 VisibleText,
1667 AccessibilityNode,
1668 AccessibilityLabel,
1669 Control,
1670 ShadowBoundary,
1671 FrameBoundary,
1672 Canvas,
1673 BoundaryScan,
1674 MutationRace,
1675}
1676
1677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1679#[serde(rename_all = "snake_case")]
1680pub enum ActionKind {
1681 Click,
1682 ClickExpectPopup,
1683 DoubleClick,
1684 Hover,
1685 Drag,
1686 Type,
1687 KeyDown,
1688 KeyUp,
1689 KeyPress,
1690 Shortcut,
1691 Clear,
1692 Check,
1693 Uncheck,
1694 Select,
1695 Upload,
1696 Scroll,
1697}
1698
1699#[derive(Debug, Clone, Serialize)]
1701pub struct ActionTarget {
1702 pub label: String,
1703 #[serde(skip_serializing_if = "Option::is_none")]
1704 pub reference: Option<String>,
1705}
1706
1707#[derive(Debug, Clone, Serialize)]
1712pub struct ActionOutcome {
1713 pub action: ActionKind,
1714 #[serde(skip_serializing_if = "Option::is_none")]
1715 pub target: Option<ActionTarget>,
1716 pub revision: u64,
1717 pub target_id: String,
1718 pub frame_id: String,
1719 #[serde(skip_serializing_if = "Option::is_none")]
1720 pub evidence: Option<Value>,
1721}
1722
1723#[derive(Debug, Clone, Serialize)]
1726pub struct CoordinateClickOutcome {
1727 pub x: f64,
1728 pub y: f64,
1729 pub hit: Option<CoordinateHit>,
1730 pub revision: u64,
1731 pub target_id: String,
1732 pub frame_id: String,
1733}
1734
1735#[derive(Debug, Clone, Serialize)]
1736pub struct CoordinateHit {
1737 pub tag: String,
1738 pub role: Option<String>,
1739 pub name: Option<String>,
1740}
1741
1742#[derive(Debug, Clone, Serialize)]
1744pub struct PopupClickOutcome {
1745 pub action: ActionKind,
1746 pub target: ActionTarget,
1747 pub revision: u64,
1748 pub target_id: String,
1749 pub frame_id: String,
1750 pub causally_verified_popup: bool,
1751 pub popup_id: String,
1752 pub opener_id: String,
1753 pub evidence: PopupVerificationEvidence,
1754}
1755
1756#[derive(Debug, Clone, Serialize)]
1758pub struct PopupVerificationEvidence {
1759 pub trusted_click_witness: bool,
1760 pub release_acknowledged: bool,
1761 pub release_ack_wait_ms: f64,
1762 pub topology_sequence_before_release: u64,
1763 pub popup_observed_sequence: u64,
1764 pub attached: bool,
1765 pub ready_state: String,
1766}
1767
1768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1770#[serde(rename_all = "snake_case")]
1771pub enum PopupClickErrorKind {
1772 ReleaseFailed,
1773 WitnessMissing,
1774 TopologyLagged,
1775 PopupMissing,
1776 PopupAmbiguous,
1777 PopupDestroyed,
1778 PopupOpenerMismatch,
1779 PopupUnreadable,
1780}
1781
1782#[derive(Debug, Clone, Serialize)]
1784pub struct PopupClickError {
1785 pub kind: PopupClickErrorKind,
1786 pub message: String,
1787}
1788
1789impl std::fmt::Display for PopupClickError {
1790 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1791 write!(formatter, "popup click {:?}: {}", self.kind, self.message)
1792 }
1793}
1794
1795impl Error for PopupClickError {}
1796
1797#[derive(Debug, Clone)]
1798pub(crate) struct PopupTopologySnapshot {
1799 pub(crate) original_target_id: String,
1800 pub(crate) original_frame_id: String,
1801 pub(crate) preexisting_target_ids: HashSet<String>,
1802 pub(crate) sequence: u64,
1803 pub(crate) event_loss_count: u64,
1804}
1805
1806#[derive(Debug, Clone)]
1807pub(crate) struct PopupCandidate {
1808 pub(crate) target: PageTargetInfo,
1809 pub(crate) observed_sequence: u64,
1810}
1811
1812#[derive(Debug, Clone)]
1813pub(crate) struct CompactPageContext {
1814 pub(crate) page: PageInfo,
1815 pub(crate) text: String,
1816 pub(crate) accessibility: CompactAccessibilitySnapshot,
1817 pub(crate) consistency: ObservationConsistency,
1818 pub(crate) boundaries: ObservationBoundarySummary,
1819 pub(crate) incomplete: Vec<ObservationIncompleteReason>,
1820}
1821
1822impl CompactPageContext {
1823 pub(crate) fn into_page_context(self) -> PageContext {
1824 PageContext {
1825 page: self.page,
1826 text: self.text,
1827 dom: None,
1828 accessibility: self.accessibility,
1829 consistency: self.consistency,
1830 boundaries: self.boundaries,
1831 incomplete: self.incomplete,
1832 screenshot: None,
1833 }
1834 }
1835}
1836
1837#[derive(Debug, Deserialize)]
1838pub(crate) struct EvaluatedPageState {
1839 pub(crate) url: String,
1840 pub(crate) title: String,
1841 #[serde(default)]
1842 pub(crate) ready_state: String,
1843 #[serde(default)]
1844 pub(crate) mutation_revision: u64,
1845 #[serde(default)]
1846 pub(crate) boundaries: ObservationBoundarySummary,
1847 pub(crate) text: String,
1848}
1849
1850impl AccessibilitySnapshot {
1851 pub fn format(&self) -> String {
1852 let mut output = format!(
1853 "url: {}\ntitle: {}\nreadyState: {}\n\n{}",
1854 self.page.url,
1855 self.page.title,
1856 self.page.ready_state,
1857 format_tree(&self.roots, 0)
1858 );
1859 if !self.interactive.is_empty() {
1860 output.push_str("\nInteractive elements:\n");
1861 for element in &self.interactive {
1862 output.push_str(&format!(
1863 "{} [{}] {}\n",
1864 element.reference, element.role, element.name
1865 ));
1866 }
1867 }
1868 output
1869 }
1870}
1871
1872#[derive(Debug, Clone, Serialize)]
1879pub struct FailureTracePack {
1880 pub outcome: ActionOutcome,
1882 pub error: String,
1884 #[serde(skip_serializing_if = "Option::is_none")]
1886 pub last_observation: Option<CompactObservationTrace>,
1887 pub topology: TopologyTrace,
1889 pub trace_bytes: usize,
1891}
1892
1893#[derive(Debug, Clone, Serialize)]
1895pub struct CompactObservationTrace {
1896 pub page: PageInfo,
1897 pub revision: u64,
1898 #[serde(skip_serializing_if = "Vec::is_empty")]
1899 pub interactive: Vec<CompactInteractiveElement>,
1900 #[serde(skip_serializing_if = "Option::is_none")]
1901 pub completeness: Option<ObservationCompleteness>,
1902}
1903
1904#[derive(Debug, Clone, Serialize)]
1906pub struct TopologyTrace {
1907 pub sequence: u64,
1908 pub active_target_id: Option<String>,
1909 pub active_frame_id: Option<String>,
1910 pub target_count: usize,
1911 pub frame_count: usize,
1912 pub event_loss_count: u64,
1913}
1914
1915pub struct BrowserSession {
1917 pub(crate) cdp: CdpClient,
1918 pub(crate) chrome: Option<ChromeProcess>,
1919 pub(crate) disposable_profile: Option<DisposableProfileDir>,
1920 pub(crate) launched_incognito_context_id: Option<String>,
1921 pub(crate) profile: String,
1922 pub(crate) interaction_mode: InteractionMode,
1923 pub(crate) mouse: MouseEngine,
1924 pub(crate) pointer: Mutex<Option<Point>>,
1925 pub(crate) page_revision: Arc<AtomicU64>,
1926 pub(crate) observation_cache: Mutex<Option<CachedObservation>>,
1927 pub(crate) network_wait_leases: Arc<Mutex<NetworkLeaseState>>,
1928 pub(crate) diagnostic_leases: Arc<Mutex<DiagnosticLeaseState>>,
1929 pub(crate) download_scope: Arc<Mutex<()>>,
1930 pub(crate) topology: Arc<Mutex<TopologyRegistry>>,
1931 pub(crate) popup_click_scope: Mutex<()>,
1932 pub(crate) upload_root: PathBuf,
1933 pub(crate) policy: BrowserPolicy,
1934 pub(crate) policy_interception: Option<PolicyInterception>,
1935 pub(crate) audit_log: std::sync::Mutex<VecDeque<AuditEntry>>,
1936 pub(crate) audit_sequence: AtomicU64,
1937 pub(crate) audit_enabled: bool,
1938}
1939
1940pub(crate) struct CachedObservation {
1941 pub(crate) revision: u64,
1942 pub(crate) context: CompactPageContext,
1943}
1944
1945pub(crate) type PausedPolicyRequests = Arc<Mutex<HashSet<(Option<String>, String)>>>;
1946
1947pub(crate) struct PolicyInterception {
1948 pub(crate) cdp: CdpClient,
1949 pub(crate) sessions: Arc<Mutex<HashSet<String>>>,
1950 pub(crate) paused: PausedPolicyRequests,
1951 pub(crate) last_denial: Arc<Mutex<Option<PolicyError>>>,
1952 pub(crate) worker: tokio::task::JoinHandle<()>,
1953}
1954
1955impl PolicyInterception {
1956 pub(crate) async fn start(
1957 cdp: CdpClient,
1958 policy: BrowserPolicy,
1959 initial_session: String,
1960 ) -> BrowserResult<Self> {
1961 let mut events = cdp.subscribe_events_with_params();
1962 let sessions = Arc::new(Mutex::new(HashSet::from([initial_session.clone()])));
1963 let paused = Arc::new(Mutex::new(HashSet::new()));
1964 let last_denial = Arc::new(Mutex::new(None));
1965 let worker_cdp = cdp.clone();
1966 let worker_sessions = Arc::clone(&sessions);
1967 let worker_paused = Arc::clone(&paused);
1968 let worker_denial = Arc::clone(&last_denial);
1969 let worker = tokio::spawn(async move {
1970 loop {
1971 let event = match events.recv().await {
1972 Ok(event) => event,
1973 Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
1974 *worker_denial.lock().await = Some(PolicyError::Denied {
1975 operation: "navigation".to_string(),
1976 reason: format!(
1977 "policy event stream lagged by {count}; paused requests remain blocked"
1978 ),
1979 });
1980 continue;
1981 }
1982 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1983 };
1984 if event.method == "Target.attachedToTarget" {
1985 if let Some(session_id) = event.params["sessionId"].as_str() {
1986 let session_id = session_id.to_string();
1987 if enable_fetch_for(&worker_cdp, &session_id).await.is_ok() {
1988 worker_sessions.lock().await.insert(session_id.clone());
1989 let _ = worker_cdp
1990 .send_to_session(
1991 &session_id,
1992 "Runtime.runIfWaitingForDebugger",
1993 None,
1994 )
1995 .await;
1996 }
1997 }
1998 continue;
1999 }
2000 if event.method != "Fetch.requestPaused" {
2001 continue;
2002 }
2003 let Some(request_id) = event.params["requestId"].as_str() else {
2004 continue;
2005 };
2006 let request_id = request_id.to_string();
2007 let key = (event.session_id.clone(), request_id.clone());
2008 worker_paused.lock().await.insert(key.clone());
2009 let url = event.params["request"]["url"].as_str().unwrap_or_default();
2010 let decision = policy.require_url(url).await;
2011 let (method, params) = match decision {
2012 Ok(_) => (
2013 "Fetch.continueRequest",
2014 serde_json::json!({"requestId": &request_id}),
2015 ),
2016 Err(error) => {
2017 *worker_denial.lock().await = Some(error);
2018 (
2019 "Fetch.failRequest",
2020 serde_json::json!({
2021 "requestId": &request_id,
2022 "errorReason": "BlockedByClient"
2023 }),
2024 )
2025 }
2026 };
2027 let _ = match event.session_id.as_deref() {
2028 Some(session_id) => {
2029 worker_cdp
2030 .send_to_session(session_id, method, Some(params))
2031 .await
2032 }
2033 None => worker_cdp.send(method, Some(params)).await,
2034 };
2035 worker_paused.lock().await.remove(&key);
2036 }
2037 });
2038 if let Err(error) = enable_fetch_for(&cdp, &initial_session).await {
2039 worker.abort();
2040 return Err(error);
2041 }
2042 Ok(Self {
2043 cdp,
2044 sessions,
2045 paused,
2046 last_denial,
2047 worker,
2048 })
2049 }
2050
2051 async fn take_denial(&self) -> Option<PolicyError> {
2052 self.last_denial.lock().await.take()
2053 }
2054
2055 async fn shutdown(self) {
2056 for (session_id, request_id) in self.paused.lock().await.clone() {
2057 let params = Some(serde_json::json!({
2058 "requestId": request_id,
2059 "errorReason": "Aborted"
2060 }));
2061 let _ = match session_id.as_deref() {
2062 Some(session_id) => {
2063 self.cdp
2064 .send_to_session(session_id, "Fetch.failRequest", params)
2065 .await
2066 }
2067 None => self.cdp.send("Fetch.failRequest", params).await,
2068 };
2069 }
2070 for session_id in self.sessions.lock().await.clone() {
2071 let _ = disable_fetch_for(&self.cdp, Some(&session_id)).await;
2072 }
2073 self.worker.abort();
2074 }
2075}
2076
2077#[derive(Debug)]
2083pub(crate) struct DisposableProfileDir {
2084 pub(crate) path: PathBuf,
2085}
2086
2087pub(crate) const DISPOSABLE_OWNER_FILE: &str = ".glass-owner.json";
2088pub(crate) const DISPOSABLE_CLEANUP_BATCH: usize = 1024;
2089
2090#[derive(Debug, Serialize, Deserialize)]
2091pub(crate) struct DisposableProfileOwner {
2092 pub(crate) pid: u32,
2093 pub(crate) process_start: u64,
2094}
2095
2096impl DisposableProfileDir {
2097 fn create() -> BrowserResult<Self> {
2098 static NEXT_DISPOSABLE_PROFILE: AtomicU64 = AtomicU64::new(0);
2099
2100 let root = std::env::temp_dir().join("glass");
2101 std::fs::create_dir_all(&root)?;
2102 Self::cleanup_abandoned(&root)?;
2103 let pid = std::process::id();
2104 let process_start = process_start_identity(pid)
2105 .ok_or("could not determine Glass process start identity")?;
2106 for _ in 0..32 {
2107 let sequence = NEXT_DISPOSABLE_PROFILE.fetch_add(1, Ordering::Relaxed);
2108 let nonce = format!(
2109 "{}-{}-{sequence}",
2110 std::process::id(),
2111 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
2112 );
2113 let path = root.join(format!("incognito-{nonce}"));
2114 match std::fs::create_dir(&path) {
2115 Ok(()) => {
2116 let owner = DisposableProfileOwner { pid, process_start };
2117 let owner_json = serde_json::to_vec(&owner)?;
2118 if let Err(error) = std::fs::write(path.join(DISPOSABLE_OWNER_FILE), owner_json)
2119 {
2120 let _ = std::fs::remove_dir_all(&path);
2121 return Err(error.into());
2122 }
2123 return Ok(Self { path });
2124 }
2125 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2126 Err(error) => return Err(error.into()),
2127 }
2128 }
2129 Err("could not allocate a unique incognito user-data directory".into())
2130 }
2131
2132 fn path(&self) -> &Path {
2133 &self.path
2134 }
2135
2136 fn cleanup_abandoned(root: &Path) -> BrowserResult<()> {
2137 let mut candidates = Vec::new();
2138 for entry in std::fs::read_dir(root)? {
2139 let entry = entry?;
2140 if !entry.file_type()?.is_dir()
2141 || !entry
2142 .file_name()
2143 .to_string_lossy()
2144 .starts_with("incognito-")
2145 {
2146 continue;
2147 }
2148 let bytes = match std::fs::read(entry.path().join(DISPOSABLE_OWNER_FILE)) {
2149 Ok(bytes) => bytes,
2150 Err(_) => continue,
2151 };
2152 let owner = match serde_json::from_slice::<DisposableProfileOwner>(&bytes) {
2153 Ok(owner) if owner.pid != 0 && owner.process_start != 0 => owner,
2154 _ => continue,
2155 };
2156 candidates.push((entry.path(), owner));
2157 if candidates.len() == DISPOSABLE_CLEANUP_BATCH {
2158 reap_disposable_candidates(&mut candidates)?;
2159 }
2160 }
2161 reap_disposable_candidates(&mut candidates)
2162 }
2163}
2164
2165pub(crate) fn reap_disposable_candidates(
2166 candidates: &mut Vec<(PathBuf, DisposableProfileOwner)>,
2167) -> BrowserResult<()> {
2168 if candidates.is_empty() {
2169 return Ok(());
2170 }
2171 let pids = candidates
2172 .iter()
2173 .map(|(_, owner)| Pid::from_u32(owner.pid))
2174 .collect::<Vec<_>>();
2175 let mut system = System::new();
2176 system.refresh_processes(ProcessesToUpdate::Some(&pids), true);
2177 for (path, owner) in candidates.drain(..) {
2178 let live_start = system
2179 .process(Pid::from_u32(owner.pid))
2180 .map(|process| process.start_time());
2181 if live_start != Some(owner.process_start)
2182 && let Err(error) = std::fs::remove_dir_all(path)
2183 && error.kind() != std::io::ErrorKind::NotFound
2184 {
2185 return Err(error.into());
2186 }
2187 }
2188 Ok(())
2189}
2190
2191pub(crate) fn process_start_identity(pid: u32) -> Option<u64> {
2192 let pid = Pid::from_u32(pid);
2193 let mut system = System::new();
2194 system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
2195 system.process(pid).map(|process| process.start_time())
2196}
2197
2198impl Drop for DisposableProfileDir {
2199 fn drop(&mut self) {
2200 if let Err(error) = std::fs::remove_dir_all(&self.path)
2201 && error.kind() != std::io::ErrorKind::NotFound
2202 {
2203 tracing::warn!(path = %self.path.display(), %error, "could not remove disposable incognito profile");
2204 }
2205 }
2206}
2207
2208pub(crate) async fn wait_for_stable_popup_topology(
2209 topology: &Arc<Mutex<TopologyRegistry>>,
2210 snapshot: &PopupTopologySnapshot,
2211 candidate: &PopupCandidate,
2212 deadline: tokio::time::Instant,
2213 quiet_interval: Duration,
2214) -> Result<PopupCandidate, PopupClickError> {
2215 let mut quiet_since = tokio::time::Instant::now();
2216 let mut stable_state = None;
2217 loop {
2218 let (current, state) = {
2219 let topology = topology.lock().await;
2220 let current = assess_popup_topology(snapshot, &topology, true)?;
2221 (current, (topology.sequence, topology.event_loss_count))
2222 };
2223 if current.target.id != candidate.target.id {
2224 return Err(popup_typed_error(
2225 PopupClickErrorKind::PopupAmbiguous,
2226 "popup candidate changed during topology stabilization",
2227 ));
2228 }
2229
2230 let now = tokio::time::Instant::now();
2231 if stable_state != Some(state) {
2232 stable_state = Some(state);
2233 quiet_since = now;
2234 } else if now.duration_since(quiet_since) >= quiet_interval {
2235 return Ok(current);
2236 }
2237 if now >= deadline {
2238 return Err(popup_typed_error(
2239 PopupClickErrorKind::TopologyLagged,
2240 "popup topology did not stabilize before the evidence deadline",
2241 ));
2242 }
2243
2244 let quiet_remaining = quiet_interval.saturating_sub(now.duration_since(quiet_since));
2245 let deadline_remaining = deadline.saturating_duration_since(now);
2246 tokio::time::sleep(
2247 POPUP_TOPOLOGY_POLL_INTERVAL
2248 .min(quiet_remaining)
2249 .min(deadline_remaining),
2250 )
2251 .await;
2252 }
2253}
2254
2255pub(crate) fn interactive_elements(roots: &[AxNode], revision: u64) -> Vec<InteractiveElement> {
2256 find_interactive_elements(roots)
2257 .into_iter()
2258 .filter_map(|node| {
2259 let backend_dom_node_id = node.backend_dom_node_id?;
2260 Some(InteractiveElement {
2261 reference: backend_node_reference(revision, backend_dom_node_id),
2262 role: node.role.clone(),
2263 name: node.name.clone(),
2264 description: node.description.clone(),
2265 backend_dom_node_id,
2266 input_type: None,
2267 })
2268 })
2269 .collect()
2270}
2271
2272pub(crate) fn truncate_visible_text(text: &str, max_bytes: usize) -> String {
2273 truncate_visible_text_with_status(text, max_bytes).0
2274}
2275
2276pub(crate) fn collect_diagnostic_event(
2277 event: &CdpEventWithParams,
2278 console: &mut Vec<ConsoleEvidence>,
2279 network: &mut Vec<NetworkEvidence>,
2280 request_indexes: &mut HashMap<String, usize>,
2281 dropped: &mut u64,
2282) {
2283 match event.method.as_str() {
2284 "Runtime.consoleAPICalled" => {
2285 push_bounded(
2286 console,
2287 ConsoleEvidence {
2288 level: bounded_diagnostic_text(event.params["type"].as_str().unwrap_or("log")),
2289 text: "[console arguments redacted]".to_string(),
2290 },
2291 dropped,
2292 );
2293 }
2294 "Log.entryAdded" => {
2295 let entry = &event.params["entry"];
2296 push_bounded(
2297 console,
2298 ConsoleEvidence {
2299 level: bounded_diagnostic_text(entry["level"].as_str().unwrap_or("log")),
2300 text: redact_diagnostic_text(entry["text"].as_str().unwrap_or("")),
2301 },
2302 dropped,
2303 );
2304 }
2305 "Network.requestWillBeSent" => {
2306 let Some(request_id) = event.params["requestId"].as_str() else {
2307 return;
2308 };
2309 if let Some(index) = request_indexes.get(request_id).copied() {
2310 network[index].redirect_count = network[index].redirect_count.saturating_add(1);
2311 return;
2312 }
2313 if network.len() >= MAX_DIAGNOSTIC_EVENTS {
2314 *dropped = dropped.saturating_add(1);
2315 return;
2316 }
2317 let request = &event.params["request"];
2318 let index = network.len();
2319 request_indexes.insert(request_id.to_string(), index);
2320 network.push(NetworkEvidence {
2321 request_id: bounded_diagnostic_text(request_id),
2322 method: bounded_diagnostic_text(request["method"].as_str().unwrap_or("")),
2323 url: redact_diagnostic_url(request["url"].as_str().unwrap_or("")),
2324 status: None,
2325 failure: None,
2326 safe_header_names: safe_header_names(&request["headers"]),
2327 redirect_count: u16::from(event.params.get("redirectResponse").is_some()),
2328 });
2329 }
2330 "Network.responseReceived" => {
2331 if let Some(index) = event.params["requestId"]
2332 .as_str()
2333 .and_then(|id| request_indexes.get(id))
2334 .copied()
2335 {
2336 network[index].status = event.params["response"]["status"]
2337 .as_u64()
2338 .and_then(|status| u16::try_from(status).ok());
2339 }
2340 }
2341 "Network.loadingFailed" => {
2342 if let Some(index) = event.params["requestId"]
2343 .as_str()
2344 .and_then(|id| request_indexes.get(id))
2345 .copied()
2346 {
2347 network[index].failure = Some(bounded_diagnostic_text(
2348 event.params["errorText"]
2349 .as_str()
2350 .unwrap_or("request_failed"),
2351 ));
2352 }
2353 }
2354 _ => {}
2355 }
2356}
2357
2358pub(crate) fn push_bounded<T>(values: &mut Vec<T>, value: T, dropped: &mut u64) {
2359 if values.len() < MAX_DIAGNOSTIC_EVENTS {
2360 values.push(value);
2361 } else {
2362 *dropped = dropped.saturating_add(1);
2363 }
2364}
2365
2366pub(crate) fn bounded_diagnostic_text(value: &str) -> String {
2367 truncate_utf8_bytes(value, MAX_DIAGNOSTIC_TEXT_BYTES)
2368}
2369
2370pub(crate) fn redact_diagnostic_text(value: &str) -> String {
2371 let lower = value.to_ascii_lowercase();
2372 if ["authorization", "cookie", "password", "token", "secret"]
2373 .iter()
2374 .any(|marker| lower.contains(marker))
2375 {
2376 return "[redacted sensitive console entry]".to_string();
2377 }
2378 let redacted = value
2379 .split_whitespace()
2380 .map(|part| {
2381 if part.starts_with("http://") || part.starts_with("https://") {
2382 redact_diagnostic_url(part)
2383 } else {
2384 part.to_string()
2385 }
2386 })
2387 .collect::<Vec<_>>()
2388 .join(" ");
2389 bounded_diagnostic_text(&redacted)
2390}
2391
2392pub(crate) fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String {
2393 let mut end = value.len().min(max_bytes);
2394 while end > 0 && !value.is_char_boundary(end) {
2395 end -= 1;
2396 }
2397 value[..end].to_string()
2398}
2399
2400pub(crate) fn redact_diagnostic_url(value: &str) -> String {
2401 let Ok(mut url) = url::Url::parse(value) else {
2402 return truncate_utf8_bytes(
2403 value.split('?').next().unwrap_or(""),
2404 MAX_DIAGNOSTIC_URL_BYTES,
2405 );
2406 };
2407 let _ = url.set_username("");
2408 let _ = url.set_password(None);
2409 if url.query().is_some() {
2410 let names = url
2411 .query_pairs()
2412 .map(|(name, _)| name.into_owned())
2413 .collect::<Vec<_>>();
2414 url.set_query(None);
2415 if !names.is_empty() {
2416 url.query_pairs_mut()
2417 .extend_pairs(names.iter().map(|name| (name.as_str(), "[redacted]")));
2418 }
2419 }
2420 url.set_fragment(None);
2421 truncate_utf8_bytes(url.as_str(), MAX_DIAGNOSTIC_URL_BYTES)
2422}
2423
2424pub(crate) fn safe_header_names(headers: &Value) -> Vec<String> {
2425 let mut names = headers
2426 .as_object()
2427 .into_iter()
2428 .flat_map(|headers| headers.keys())
2429 .filter(|name| {
2430 !matches!(
2431 name.to_ascii_lowercase().as_str(),
2432 "authorization" | "proxy-authorization" | "cookie" | "set-cookie"
2433 )
2434 })
2435 .take(32)
2436 .map(|name| truncate_utf8_bytes(name, 128))
2437 .collect::<Vec<_>>();
2438 names.sort();
2439 names
2440}
2441
2442pub(crate) fn finite_nonnegative_u64(value: &Value) -> u64 {
2443 value
2444 .as_f64()
2445 .filter(|value| value.is_finite() && *value >= 0.0)
2446 .map(|value| value.min(u64::MAX as f64) as u64)
2447 .unwrap_or(0)
2448}
2449
2450pub(crate) fn validate_visual_options(options: &VisualCaptureOptions) -> BrowserResult<()> {
2451 if !options.scale.is_finite() || !(0.1..=4.0).contains(&options.scale) {
2452 return Err("visual scale must be finite and between 0.1 and 4.0".into());
2453 }
2454 if options.full_page as u8 + options.clip.is_some() as u8 + options.target.is_some() as u8 > 1 {
2455 return Err("full-page, clip, and element capture are mutually exclusive".into());
2456 }
2457 if options.format == VisualFormat::Png && options.quality.is_some() {
2458 return Err("PNG capture does not accept quality".into());
2459 }
2460 if options.quality.is_some_and(|quality| quality > 100) {
2461 return Err("visual quality must be between 0 and 100".into());
2462 }
2463 if let Some(clip) = options.clip {
2464 for value in [clip.x, clip.y, clip.width, clip.height] {
2465 if !value.is_finite() {
2466 return Err("visual clip values must be finite".into());
2467 }
2468 }
2469 if clip.x < 0.0 || clip.y < 0.0 || clip.width <= 0.0 || clip.height <= 0.0 {
2470 return Err("visual clip must have non-negative origin and positive size".into());
2471 }
2472 validate_effective_visual_clip(Some(clip), options.scale)?;
2473 }
2474 Ok(())
2475}
2476
2477pub(crate) fn validate_effective_visual_clip(
2478 clip: Option<VisualClip>,
2479 scale: f64,
2480) -> BrowserResult<()> {
2481 let Some(clip) = clip else { return Ok(()) };
2482 let width = clip.width * scale;
2483 let height = clip.height * scale;
2484 if width > 16_384.0 || height > 16_384.0 || width * height > MAX_VISUAL_PIXELS {
2485 return Err("visual output exceeds the 16384-axis or 8-megapixel budget".into());
2486 }
2487 Ok(())
2488}
2489
2490pub(crate) fn visual_clips_match(first: VisualClip, second: VisualClip) -> bool {
2491 [
2492 (first.x, second.x),
2493 (first.y, second.y),
2494 (first.width, second.width),
2495 (first.height, second.height),
2496 ]
2497 .into_iter()
2498 .all(|(first, second)| (first - second).abs() <= 0.5)
2499}
2500
2501pub(crate) fn visual_rect(value: &Value) -> BrowserResult<VisualClip> {
2502 let clip = VisualClip {
2503 x: value["x"].as_f64().unwrap_or(0.0),
2504 y: value["y"].as_f64().unwrap_or(0.0),
2505 width: value["width"].as_f64().ok_or("visual width was missing")?,
2506 height: value["height"]
2507 .as_f64()
2508 .ok_or("visual height was missing")?,
2509 };
2510 validate_visual_options(&VisualCaptureOptions {
2511 clip: Some(clip),
2512 ..VisualCaptureOptions::default()
2513 })?;
2514 Ok(clip)
2515}
2516
2517pub(crate) fn visual_viewport_rect(value: &Value) -> BrowserResult<VisualClip> {
2518 visual_rect(&serde_json::json!({
2519 "x": value["pageX"].as_f64().unwrap_or(0.0),
2520 "y": value["pageY"].as_f64().unwrap_or(0.0),
2521 "width": value["clientWidth"].as_f64().ok_or("visual viewport width was missing")?,
2522 "height": value["clientHeight"].as_f64().ok_or("visual viewport height was missing")?
2523 }))
2524}
2525
2526pub(crate) fn decoded_base64_len(value: &str) -> BrowserResult<usize> {
2527 if !value.len().is_multiple_of(4) {
2528 return Err("visual base64 payload had invalid length".into());
2529 }
2530 let padding = value
2531 .as_bytes()
2532 .iter()
2533 .rev()
2534 .take_while(|byte| **byte == b'=')
2535 .count();
2536 Ok(value.len() / 4 * 3 - padding)
2537}
2538
2539pub(crate) async fn stop_screencast_for(
2540 cdp: &CdpClient,
2541 session_id: Option<&str>,
2542) -> BrowserResult<()> {
2543 match session_id {
2544 Some(session_id) => {
2545 cdp.send_to_session(session_id, "Page.stopScreencast", None)
2546 .await?;
2547 }
2548 None => {
2549 cdp.send("Page.stopScreencast", None).await?;
2550 }
2551 }
2552 Ok(())
2553}
2554
2555pub(crate) async fn disable_fetch_for(
2556 cdp: &CdpClient,
2557 session_id: Option<&str>,
2558) -> BrowserResult<()> {
2559 match session_id {
2560 Some(session_id) => {
2561 cdp.send_to_session(session_id, "Fetch.disable", None)
2562 .await?
2563 }
2564 None => cdp.send("Fetch.disable", None).await?,
2565 };
2566 Ok(())
2567}
2568
2569pub(crate) async fn enable_fetch_for(cdp: &CdpClient, session_id: &str) -> BrowserResult<()> {
2570 cdp.send_to_session(
2571 session_id,
2572 "Fetch.enable",
2573 Some(serde_json::json!({
2574 "patterns": [{
2575 "urlPattern": "*",
2576 "resourceType": "Document",
2577 "requestStage": "Request"
2578 }]
2579 })),
2580 )
2581 .await?;
2582 Ok(())
2583}
2584
2585pub(crate) fn visual_quad_rect(value: &Value) -> BrowserResult<VisualClip> {
2586 let values = value
2587 .as_array()
2588 .ok_or("visual element border quad was missing")?;
2589 if values.len() != 8 {
2590 return Err("visual element border quad must contain eight coordinates".into());
2591 }
2592 let coordinates = values
2593 .iter()
2594 .map(|value| {
2595 value
2596 .as_f64()
2597 .ok_or("visual border coordinate was not numeric")
2598 })
2599 .collect::<Result<Vec<_>, _>>()?;
2600 let xs = [
2601 coordinates[0],
2602 coordinates[2],
2603 coordinates[4],
2604 coordinates[6],
2605 ];
2606 let ys = [
2607 coordinates[1],
2608 coordinates[3],
2609 coordinates[5],
2610 coordinates[7],
2611 ];
2612 let left = xs.into_iter().fold(f64::INFINITY, f64::min);
2613 let right = xs.into_iter().fold(f64::NEG_INFINITY, f64::max);
2614 let top = ys.into_iter().fold(f64::INFINITY, f64::min);
2615 let bottom = ys.into_iter().fold(f64::NEG_INFINITY, f64::max);
2616 let width = right - left;
2617 let height = bottom - top;
2618 if width <= 0.0 || height <= 0.0 {
2619 return Err("visual element has empty geometry".into());
2620 }
2621 Ok(VisualClip {
2622 x: left,
2623 y: top,
2624 width,
2625 height,
2626 })
2627}
2628
2629pub(crate) fn truncate_visible_text_with_status(text: &str, max_bytes: usize) -> (String, bool) {
2630 if text.len() <= max_bytes {
2631 return (text.to_string(), false);
2632 }
2633
2634 let content_limit = max_bytes.saturating_sub(TEXT_TRUNCATION_MARKER.len());
2635 let mut end = content_limit;
2636 while end > 0 && !text.is_char_boundary(end) {
2637 end -= 1;
2638 }
2639
2640 let mut truncated = text[..end].to_string();
2641 if max_bytes >= TEXT_TRUNCATION_MARKER.len() {
2642 truncated.push_str(TEXT_TRUNCATION_MARKER);
2643 }
2644 (truncated, true)
2645}
2646
2647pub(crate) fn interaction_path(
2648 mode: InteractionMode,
2649 mouse: &MouseEngine,
2650 start: Point,
2651 end: Point,
2652) -> Vec<Point> {
2653 match mode {
2654 InteractionMode::Human => mouse.generate_path(start, end),
2655 InteractionMode::Fast => vec![start, end],
2656 }
2657}
2658
2659pub(crate) fn validate_key(key: &str) -> BrowserResult<()> {
2660 if key.is_empty() || key.len() > 64 || key.chars().any(char::is_control) {
2661 return Err("key must be 1..=64 printable UTF-8 bytes".into());
2662 }
2663 Ok(())
2664}
2665
2666pub(crate) fn key_code(key: &str) -> String {
2667 match key {
2668 " " => "Space".to_string(),
2669 "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "Enter" | "Tab" | "Escape"
2670 | "Backspace" | "Delete" | "Home" | "End" | "PageUp" | "PageDown" => key.to_string(),
2671 _ if key.chars().count() == 1 => {
2672 let character = key.chars().next().unwrap();
2673 if character.is_ascii_alphabetic() {
2674 format!("Key{}", character.to_ascii_uppercase())
2675 } else if character.is_ascii_digit() {
2676 format!("Digit{character}")
2677 } else {
2678 key.to_string()
2679 }
2680 }
2681 _ => key.to_string(),
2682 }
2683}
2684
2685pub(crate) fn parse_shortcut(value: &str) -> BrowserResult<(i64, String)> {
2686 if value.is_empty() || value.len() > 256 {
2687 return Err("shortcut must be 1..=256 bytes".into());
2688 }
2689 let mut modifiers = 0;
2690 let mut key = None;
2691 for part in value.split('+') {
2692 match part.to_ascii_lowercase().as_str() {
2693 "alt" => modifiers |= 1,
2694 "control" | "ctrl" => modifiers |= 2,
2695 "meta" | "cmd" | "command" => modifiers |= 4,
2696 "shift" => modifiers |= 8,
2697 _ if key.is_none() => key = Some(part.to_string()),
2698 _ => return Err("shortcut must contain exactly one non-modifier key".into()),
2699 }
2700 }
2701 let key = key.ok_or("shortcut requires a non-modifier key")?;
2702 validate_key(&key)?;
2703 Ok((modifiers, key))
2704}
2705
2706pub(crate) fn context_event_invalidates_observation(method: &str) -> bool {
2707 matches!(
2708 method,
2709 "Page.frameNavigated"
2710 | "Page.loadEventFired"
2711 | "Page.frameStartedLoading"
2712 | "Page.frameStoppedLoading"
2713 | "DOM.documentUpdated"
2714 | "DOM.childNodeInserted"
2715 | "DOM.childNodeRemoved"
2716 | "DOM.attributeModified"
2717 | "DOM.attributeRemoved"
2718 | "DOM.characterDataModified"
2719 | "DOM.setChildNodes"
2720 )
2721}
2722
2723pub(crate) fn observation_context_invalidates(method: &str) -> bool {
2724 matches!(
2725 method,
2726 "Page.frameNavigated"
2727 | "DOM.documentUpdated"
2728 | "Runtime.executionContextDestroyed"
2729 | "Runtime.executionContextsCleared"
2730 )
2731}
2732
2733#[derive(Debug, Clone, Serialize)]
2734pub struct ResolvedElement {
2735 pub node_id: Option<i64>,
2736 pub backend_dom_node_id: Option<i64>,
2737 pub label: String,
2738 pub reference: Option<String>,
2739 pub role: Option<String>,
2741 pub input_type: Option<String>,
2743}
2744
2745pub(crate) struct PressedButtonGuard {
2746 pub(crate) cdp: CdpClient,
2747 pub(crate) point: Point,
2748 pub(crate) click_count: u32,
2749 pub(crate) armed: bool,
2750}
2751
2752pub(crate) struct RemoteObjectGuard {
2753 pub(crate) cdp: CdpClient,
2754 pub(crate) session_id: Option<String>,
2755 pub(crate) object_id: String,
2756}
2757
2758impl RemoteObjectGuard {
2759 pub(crate) fn new(cdp: CdpClient, object_id: String) -> Self {
2760 let session_id = cdp.current_session_id();
2761 Self {
2762 cdp,
2763 session_id,
2764 object_id,
2765 }
2766 }
2767}
2768
2769pub(crate) struct PopupWitnessGuard {
2770 pub(crate) cdp: CdpClient,
2771 pub(crate) session_id: String,
2772 pub(crate) state_object_id: String,
2773 pub(crate) element_object_id: String,
2774 pub(crate) armed: bool,
2775}
2776
2777pub(crate) struct PopupAttachmentGuard {
2778 pub(crate) cdp: CdpClient,
2779 pub(crate) session_id: String,
2780 pub(crate) armed: bool,
2781}
2782
2783pub(crate) struct NetworkDomainGuard {
2784 pub(crate) cdp: CdpClient,
2785 pub(crate) leases: Arc<Mutex<NetworkLeaseState>>,
2786 pub(crate) session_id: Option<String>,
2787 pub(crate) armed: bool,
2788}
2789
2790pub(crate) struct DiagnosticDomainGuard {
2791 pub(crate) cdp: CdpClient,
2792 pub(crate) network: NetworkDomainGuard,
2793 pub(crate) leases: Arc<Mutex<DiagnosticLeaseState>>,
2794 pub(crate) session_id: Option<String>,
2795 pub(crate) armed: bool,
2796}
2797
2798pub(crate) struct DownloadBehaviorGuard {
2799 pub(crate) cdp: CdpClient,
2800 pub(crate) page_session_id: Option<String>,
2801 pub(crate) armed: bool,
2802}
2803
2804#[derive(Default)]
2805pub(crate) struct DiagnosticLeaseState {
2806 pub(crate) counts: HashMap<Option<String>, usize>,
2807}
2808
2809#[derive(Default)]
2810pub(crate) struct NetworkLeaseState {
2811 pub(crate) counts: HashMap<Option<String>, usize>,
2812}
2813
2814impl NetworkDomainGuard {
2815 pub(crate) async fn acquire(
2816 cdp: CdpClient,
2817 leases: Arc<Mutex<NetworkLeaseState>>,
2818 ) -> BrowserResult<Self> {
2819 let session_id = cdp.current_session_id();
2820 let mut state = leases.lock().await;
2821 let count = state.counts.entry(session_id.clone()).or_default();
2822 *count += 1;
2823 let mut guard = Self {
2824 cdp,
2825 leases: Arc::clone(&leases),
2826 session_id: session_id.clone(),
2827 armed: true,
2828 };
2829 if *count == 1
2830 && let Err(error) = guard
2831 .cdp
2832 .set_domain_enabled_for(session_id.clone(), "Network", true)
2833 .await
2834 {
2835 state.counts.remove(&session_id);
2836 guard.armed = false;
2837 drop(state);
2838 let _ = guard
2839 .cdp
2840 .set_domain_enabled_for(session_id, "Network", false)
2841 .await;
2842 return Err(error.into());
2843 }
2844 drop(state);
2845 Ok(guard)
2846 }
2847
2848 pub(crate) async fn disable(&mut self) -> BrowserResult<()> {
2849 let state = self.leases.lock().await;
2850 release_network_lease_locked(&self.cdp, &self.session_id, state).await?;
2851 self.armed = false;
2852 Ok(())
2853 }
2854}
2855
2856impl DiagnosticDomainGuard {
2857 pub(crate) async fn acquire(
2858 cdp: CdpClient,
2859 network_leases: Arc<Mutex<NetworkLeaseState>>,
2860 leases: Arc<Mutex<DiagnosticLeaseState>>,
2861 ) -> BrowserResult<Self> {
2862 let network = NetworkDomainGuard::acquire(cdp.clone(), network_leases).await?;
2863 let session_id = cdp.current_session_id();
2864 let mut state = leases.lock().await;
2865 let count = state.counts.entry(session_id.clone()).or_default();
2866 *count += 1;
2867 if *count == 1 {
2868 if let Err(error) = cdp
2869 .set_domain_enabled_for(session_id.clone(), "Runtime", true)
2870 .await
2871 {
2872 state.counts.remove(&session_id);
2873 return Err(error.into());
2874 }
2875 if let Err(error) = cdp
2876 .set_domain_enabled_for(session_id.clone(), "Log", true)
2877 .await
2878 {
2879 state.counts.remove(&session_id);
2880 let _ = cdp
2881 .set_domain_enabled_for(session_id.clone(), "Runtime", false)
2882 .await;
2883 return Err(error.into());
2884 }
2885 }
2886 drop(state);
2887 Ok(Self {
2888 cdp,
2889 network,
2890 leases,
2891 session_id,
2892 armed: true,
2893 })
2894 }
2895
2896 pub(crate) async fn disable(&mut self) -> BrowserResult<()> {
2897 let mut state = self.leases.lock().await;
2898 let count = state.counts.entry(self.session_id.clone()).or_default();
2899 *count = count.saturating_sub(1);
2900 if *count == 0 {
2901 state.counts.remove(&self.session_id);
2902 self.cdp
2903 .set_domain_enabled_for(self.session_id.clone(), "Log", false)
2904 .await?;
2905 self.cdp
2906 .set_domain_enabled_for(self.session_id.clone(), "Runtime", false)
2907 .await?;
2908 }
2909 drop(state);
2910 self.network.disable().await?;
2911 self.armed = false;
2912 Ok(())
2913 }
2914}
2915
2916impl Drop for DiagnosticDomainGuard {
2917 fn drop(&mut self) {
2918 if !self.armed {
2919 return;
2920 }
2921 let cdp = self.cdp.clone();
2922 let leases = Arc::clone(&self.leases);
2923 let session_id = self.session_id.clone();
2924 tokio::spawn(async move {
2925 let mut state = leases.lock().await;
2926 let count = state.counts.entry(session_id.clone()).or_default();
2927 *count = count.saturating_sub(1);
2928 if *count == 0 {
2929 state.counts.remove(&session_id);
2930 let _ = cdp
2931 .set_domain_enabled_for(session_id.clone(), "Log", false)
2932 .await;
2933 let _ = cdp
2934 .set_domain_enabled_for(session_id, "Runtime", false)
2935 .await;
2936 }
2937 });
2938 }
2939}
2940
2941impl DownloadBehaviorGuard {
2942 pub(crate) async fn acquire_for_incognito(
2943 cdp: CdpClient,
2944 destination: PathBuf,
2945 target_id: String,
2946 page_session_id: String,
2947 launched_context_id: String,
2948 ) -> BrowserResult<Self> {
2949 let selected_context_id = target_browser_context_id(&cdp, &target_id, true).await?;
2950 if selected_context_id.as_deref() != Some(launched_context_id.as_str()) {
2951 return Err(download_error(
2952 DownloadErrorKind::AuthorizationFailed,
2953 "selected target does not belong to the launched incognito context",
2954 )
2955 .into());
2956 }
2957 Self::acquire(cdp, destination, Some(page_session_id)).await
2958 }
2959
2960 pub(crate) async fn acquire(
2961 cdp: CdpClient,
2962 destination: PathBuf,
2963 page_session_id: Option<String>,
2964 ) -> BrowserResult<Self> {
2965 let (sender, receiver) = tokio::sync::oneshot::channel();
2966 tokio::spawn(async move {
2967 let result = Self::acquire_owned(cdp, &destination, page_session_id).await;
2968 let _ = sender.send(result);
2969 });
2970 receiver
2971 .await
2972 .map_err(|_| {
2973 download_error(
2974 DownloadErrorKind::AuthorizationFailed,
2975 "download authorization worker ended without a result",
2976 )
2977 })?
2978 .map_err(Into::into)
2979 }
2980
2981 pub(crate) async fn acquire_owned(
2982 cdp: CdpClient,
2983 destination: &Path,
2984 page_session_id: Option<String>,
2985 ) -> Result<Self, DownloadError> {
2986 cdp.set_download_behavior("allow", Some(destination), true)
2987 .await
2988 .map_err(|error| {
2989 download_error(
2990 DownloadErrorKind::AuthorizationFailed,
2991 format!("browser download authorization failed: {error}"),
2992 )
2993 })?;
2994 if let Some(session_id) = page_session_id.as_deref()
2995 && let Err(error) = cdp
2996 .send_to_session(
2997 session_id,
2998 "Page.setDownloadBehavior",
2999 Some(serde_json::json!({
3000 "behavior": "allow",
3001 "downloadPath": destination.to_string_lossy()
3002 })),
3003 )
3004 .await
3005 {
3006 let restoration = cdp.set_download_behavior("deny", None, false).await;
3007 return Err(download_error(
3008 DownloadErrorKind::AuthorizationFailed,
3009 format!(
3010 "incognito page download authorization failed: {error}; browser deny restoration: {}",
3011 restoration
3012 .map(|_| "completed".to_string())
3013 .unwrap_or_else(|restore| restore.to_string())
3014 ),
3015 ));
3016 }
3017 Ok(Self {
3018 cdp,
3019 page_session_id,
3020 armed: true,
3021 })
3022 }
3023
3024 pub(crate) async fn disable(&mut self) -> BrowserResult<()> {
3025 let page_result = match self.page_session_id.as_deref() {
3026 Some(session_id) => self
3027 .cdp
3028 .send_to_session(
3029 session_id,
3030 "Page.setDownloadBehavior",
3031 Some(serde_json::json!({"behavior": "deny"})),
3032 )
3033 .await
3034 .map(|_| ()),
3035 None => Ok(()),
3036 };
3037 let browser_result = self
3038 .cdp
3039 .set_download_behavior("deny", None, false)
3040 .await
3041 .map(|_| ());
3042 match (page_result, browser_result) {
3043 (Ok(()), Ok(())) => {
3044 self.armed = false;
3045 Ok(())
3046 }
3047 (page, browser) => Err(download_error(
3048 DownloadErrorKind::RestorationFailed,
3049 format!(
3050 "download deny restoration failed (page: {}; browser: {})",
3051 protocol_result_summary(page),
3052 protocol_result_summary(browser)
3053 ),
3054 )
3055 .into()),
3056 }
3057 }
3058}
3059
3060impl Drop for DownloadBehaviorGuard {
3061 fn drop(&mut self) {
3062 if !self.armed {
3063 return;
3064 }
3065 let cdp = self.cdp.clone();
3066 let page_session_id = self.page_session_id.clone();
3067 tokio::spawn(async move {
3068 if let Some(session_id) = page_session_id.as_deref() {
3069 let _ = cdp
3070 .send_to_session(
3071 session_id,
3072 "Page.setDownloadBehavior",
3073 Some(serde_json::json!({"behavior": "deny"})),
3074 )
3075 .await;
3076 }
3077 let _ = cdp.set_download_behavior("deny", None, false).await;
3078 });
3079 }
3080}
3081
3082impl Drop for NetworkDomainGuard {
3083 fn drop(&mut self) {
3084 if !self.armed {
3085 return;
3086 }
3087 let cdp = self.cdp.clone();
3088 let leases = Arc::clone(&self.leases);
3089 let session_id = self.session_id.clone();
3090 tokio::spawn(async move {
3091 let _ = release_network_lease(&cdp, &leases, &session_id).await;
3092 });
3093 }
3094}
3095
3096pub(crate) async fn release_network_lease(
3097 cdp: &CdpClient,
3098 leases: &Mutex<NetworkLeaseState>,
3099 session_id: &Option<String>,
3100) -> BrowserResult<()> {
3101 release_network_lease_locked(cdp, session_id, leases.lock().await).await
3102}
3103
3104pub(crate) async fn release_network_lease_locked(
3105 cdp: &CdpClient,
3106 session_id: &Option<String>,
3107 mut state: tokio::sync::MutexGuard<'_, NetworkLeaseState>,
3108) -> BrowserResult<()> {
3109 let count = state.counts.entry(session_id.clone()).or_default();
3110 *count = count.saturating_sub(1);
3111 if *count == 0 {
3112 state.counts.remove(session_id);
3113 cdp.set_domain_enabled_for(session_id.clone(), "Network", false)
3114 .await?;
3115 }
3116 Ok(())
3117}
3118
3119impl Drop for RemoteObjectGuard {
3120 fn drop(&mut self) {
3121 let cdp = self.cdp.clone();
3122 let session_id = self.session_id.clone();
3123 let object_id = self.object_id.clone();
3124 tokio::spawn(async move {
3125 if let Some(session_id) = session_id {
3126 let _ = cdp
3127 .release_object_for_session(&session_id, &object_id)
3128 .await;
3129 } else {
3130 let _ = cdp.release_object(&object_id).await;
3131 }
3132 });
3133 }
3134}
3135
3136impl PopupWitnessGuard {
3137 pub(crate) async fn fired(&self) -> BrowserResult<bool> {
3138 let value = popup_verification_call(
3139 self.cdp.send_to_session(
3140 &self.session_id,
3141 "Runtime.callFunctionOn",
3142 Some(serde_json::json!({
3143 "objectId": self.state_object_id,
3144 "functionDeclaration": "function(){return this.read();}",
3145 "returnByValue": true,
3146 "awaitPromise": false
3147 })),
3148 ),
3149 "popup witness read",
3150 )
3151 .await?;
3152 value["result"]["value"].as_bool().ok_or_else(|| {
3153 popup_error(
3154 PopupClickErrorKind::WitnessMissing,
3155 "popup witness returned no trusted-click state",
3156 )
3157 })
3158 }
3159
3160 pub(crate) async fn cleanup(&mut self) -> BrowserResult<()> {
3161 cleanup_popup_witness(
3162 &self.cdp,
3163 &self.session_id,
3164 &self.state_object_id,
3165 &self.element_object_id,
3166 )
3167 .await?;
3168 self.armed = false;
3169 Ok(())
3170 }
3171}
3172
3173impl Drop for PopupWitnessGuard {
3174 fn drop(&mut self) {
3175 if !self.armed {
3176 return;
3177 }
3178 let cdp = self.cdp.clone();
3179 let session_id = self.session_id.clone();
3180 let state_object_id = self.state_object_id.clone();
3181 let element_object_id = self.element_object_id.clone();
3182 tokio::spawn(async move {
3183 let _ = cleanup_popup_witness(&cdp, &session_id, &state_object_id, &element_object_id)
3184 .await;
3185 });
3186 }
3187}
3188
3189impl PopupAttachmentGuard {
3190 pub(crate) async fn detach(&mut self) -> BrowserResult<()> {
3191 popup_verification_call(
3192 self.cdp.send_browser(
3193 "Target.detachFromTarget",
3194 Some(serde_json::json!({"sessionId": self.session_id})),
3195 ),
3196 "popup detach",
3197 )
3198 .await?;
3199 self.armed = false;
3200 Ok(())
3201 }
3202}
3203
3204impl Drop for PopupAttachmentGuard {
3205 fn drop(&mut self) {
3206 if !self.armed {
3207 return;
3208 }
3209 let cdp = self.cdp.clone();
3210 let session_id = self.session_id.clone();
3211 tokio::spawn(async move {
3212 let _ = tokio::time::timeout(
3213 POPUP_VERIFY_CALL_TIMEOUT,
3214 cdp.send_browser(
3215 "Target.detachFromTarget",
3216 Some(serde_json::json!({"sessionId": session_id})),
3217 ),
3218 )
3219 .await;
3220 });
3221 }
3222}
3223
3224impl Drop for PressedButtonGuard {
3225 fn drop(&mut self) {
3226 if !self.armed {
3227 return;
3228 }
3229 let cdp = self.cdp.clone();
3230 let point = self.point;
3231 let click_count = self.click_count;
3232 tokio::spawn(async move {
3233 let _ = cdp
3234 .dispatch_mouse_event(
3235 "mouseReleased",
3236 point.x,
3237 point.y,
3238 Some("left"),
3239 Some(click_count),
3240 )
3241 .await;
3242 });
3243 }
3244}
3245
3246impl WaitCondition {
3247 pub fn parse(value: &str) -> BrowserResult<Self> {
3248 if value.len() > MAX_WAIT_CONDITION_BYTES {
3249 return Err("wait condition exceeds 4096 bytes".into());
3250 }
3251 let (kind, argument) = value
3252 .split_once('=')
3253 .ok_or("wait condition must use <kind>=<value>")?;
3254 if argument.is_empty() {
3255 return Err("wait condition value cannot be empty".into());
3256 }
3257 Ok(match kind {
3258 "lifecycle" if matches!(argument, "load" | "domcontentloaded" | "complete") => {
3259 Self::Lifecycle(
3260 if argument == "load" {
3261 "complete"
3262 } else {
3263 argument
3264 }
3265 .to_string(),
3266 )
3267 }
3268 "url" => Self::UrlExact(argument.to_string()),
3269 "url-prefix" => Self::UrlPrefix(argument.to_string()),
3270 "target-attached" => Self::TargetAttached(argument.to_string()),
3271 "target-visible" => Self::TargetVisible(argument.to_string()),
3272 "target-hidden" => Self::TargetHidden(argument.to_string()),
3273 "target-enabled" => Self::TargetEnabled(argument.to_string()),
3274 "target-stable" => Self::TargetStable(argument.to_string()),
3275 "text" => Self::Text(argument.to_string()),
3276 "js" => Self::JavaScript(argument.to_string()),
3277 "network-quiet" => {
3278 let duration = Duration::from_millis(argument.parse::<u64>()?);
3279 if duration.is_zero() || duration > MAX_WAIT_DEADLINE {
3280 return Err("network quiet duration must be between 1 ms and 300000 ms".into());
3281 }
3282 Self::NetworkQuiet(duration)
3283 }
3284 "lifecycle" => return Err("unsupported lifecycle wait value".into()),
3285 _ => return Err("unknown wait condition kind".into()),
3286 })
3287 }
3288
3289 pub(crate) fn description(&self) -> String {
3290 match self {
3291 Self::Lifecycle(_) => "lifecycle".to_string(),
3292 Self::UrlExact(_) => "url_exact".to_string(),
3293 Self::UrlPrefix(_) => "url_prefix".to_string(),
3294 Self::TargetAttached(_) => "target_attached".to_string(),
3295 Self::TargetVisible(_) => "target_visible".to_string(),
3296 Self::TargetHidden(_) => "target_hidden".to_string(),
3297 Self::TargetEnabled(_) => "target_enabled".to_string(),
3298 Self::TargetStable(_) => "target_stable".to_string(),
3299 Self::Text(_) => "text".to_string(),
3300 Self::JavaScript(_) => "javascript_predicate".to_string(),
3301 Self::NetworkQuiet(_) => "network_quiet".to_string(),
3302 }
3303 }
3304
3305 pub(crate) fn validate(&self) -> BrowserResult<()> {
3306 let value = match self {
3307 Self::Lifecycle(value)
3308 | Self::UrlExact(value)
3309 | Self::UrlPrefix(value)
3310 | Self::TargetAttached(value)
3311 | Self::TargetVisible(value)
3312 | Self::TargetHidden(value)
3313 | Self::TargetEnabled(value)
3314 | Self::TargetStable(value)
3315 | Self::Text(value)
3316 | Self::JavaScript(value) => Some(value),
3317 Self::NetworkQuiet(duration) => {
3318 if duration.is_zero() || *duration > MAX_WAIT_DEADLINE {
3319 return Err("network quiet duration must be between 1 ms and 300000 ms".into());
3320 }
3321 None
3322 }
3323 };
3324 if value.is_some_and(|value| value.is_empty() || value.len() > MAX_WAIT_CONDITION_BYTES) {
3325 return Err("wait condition value must contain 1-4096 bytes".into());
3326 }
3327 Ok(())
3328 }
3329}
3330
3331pub(crate) fn bounded_wait_state(value: &str) -> String {
3332 truncate_visible_text(value, WAIT_LAST_STATE_MAX_BYTES)
3333}
3334
3335pub(crate) fn validate_wait_deadline(deadline: Duration) -> BrowserResult<()> {
3336 if deadline.is_zero() || deadline > MAX_WAIT_DEADLINE {
3337 return Err("wait deadline must be between 1 ms and 300000 ms".into());
3338 }
3339 Ok(())
3340}
3341
3342pub(crate) fn wait_timeout(condition: &str, deadline: Duration, last_state: &str) -> WaitTimeout {
3343 WaitTimeout {
3344 condition: condition.to_string(),
3345 deadline_ms: deadline.as_millis() as u64,
3346 last_state: bounded_wait_state(last_state),
3347 reason: "deadline_exceeded",
3348 }
3349}
3350
3351impl Locator {
3352 pub fn parse(value: &str) -> BrowserResult<Self> {
3353 let value = value.trim().trim_matches('"');
3354 if value.is_empty() {
3355 return Err("element target cannot be empty".into());
3356 }
3357 if parse_revisioned_reference(value)?.is_some() {
3358 return Ok(Self::Reference(value.to_string()));
3359 }
3360 if let Some(reference) = value.strip_prefix("ref=") {
3361 if reference.is_empty() {
3362 return Err("reference locator cannot be empty".into());
3363 }
3364 return Ok(Self::Reference(reference.to_string()));
3365 }
3366 if let Some(name) = value.strip_prefix("name=") {
3367 return nonempty_locator(name, "accessible name").map(Self::AccessibleName);
3368 }
3369 if let Some(text) = value.strip_prefix("text=") {
3370 return nonempty_locator(text, "text").map(Self::Text);
3371 }
3372 if let Some(selector) = value.strip_prefix("css=") {
3373 return nonempty_locator(selector, "CSS selector").map(Self::Css);
3374 }
3375 if let Some(index) = value.strip_prefix("ordinal=") {
3376 let index = index
3377 .parse::<usize>()
3378 .ok()
3379 .filter(|index| *index > 0)
3380 .ok_or("ordinal locator must be a positive one-based integer")?;
3381 return Ok(Self::Ordinal(index));
3382 }
3383 if let Some(rest) = value.strip_prefix("role=") {
3384 let (role, name) = rest
3385 .split_once(";name=")
3386 .ok_or("role locator must use role=<role>;name=<accessible name>")?;
3387 return Ok(Self::RoleAndName {
3388 role: nonempty_locator(role, "role")?,
3389 name: nonempty_locator(name, "accessible name")?,
3390 });
3391 }
3392 Ok(Self::AccessibleName(value.to_string()))
3393 }
3394}
3395
3396pub(crate) fn nonempty_locator(value: &str, kind: &str) -> BrowserResult<String> {
3397 if value.is_empty() {
3398 return Err(format!("{kind} locator cannot be empty").into());
3399 }
3400 Ok(value.to_string())
3401}
3402
3403pub(crate) fn dom_nodes_resolution(
3404 count: usize,
3405 nodes: Vec<i64>,
3406 label: String,
3407 candidate_kind: &str,
3408) -> BrowserResult<TargetResolution> {
3409 match count {
3410 0 => Ok(TargetResolution::NotFound),
3411 1 if nodes.len() == 1 => Ok(TargetResolution::Unique(ResolvedElement {
3412 node_id: Some(nodes[0]),
3413 backend_dom_node_id: None,
3414 label,
3415 reference: None,
3416 role: None,
3417 input_type: None,
3418 })),
3419 1 => Err("unique element query returned no DOM node".into()),
3420 _ => Ok(TargetResolution::Ambiguous(
3421 nodes
3422 .into_iter()
3423 .take(AMBIGUOUS_CANDIDATE_LIMIT)
3424 .enumerate()
3425 .map(|(index, _)| CandidateSummary {
3426 label: format!("{candidate_kind} {}", index + 1),
3427 reference: None,
3428 })
3429 .collect(),
3430 )),
3431 }
3432}
3433
3434pub(crate) fn css_query_expression(selector: &str) -> BrowserResult<String> {
3435 let selector = serde_json::to_string(selector)?;
3436 Ok(format!(
3437 "(() => {{ const nodes = document.querySelectorAll({selector}); const out = []; for (let i = 0; i < Math.min(nodes.length, {AMBIGUOUS_CANDIDATE_LIMIT}); i++) out.push(nodes[i]); out.glassCount = nodes.length; return out; }})()"
3438 ))
3439}
3440
3441pub(crate) fn text_query_expression(text: &str) -> BrowserResult<String> {
3442 let text = serde_json::to_string(text)?;
3443 Ok(format!(
3444 r#"(() => {{
3445 const wanted = ({text}).replace(/\s+/g, ' ').trim();
3446 const matches = [];
3447 let count = 0;
3448 const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT);
3449 for (let element = walker.currentNode; element; element = walker.nextNode()) {{
3450 const style = getComputedStyle(element);
3451 const rect = element.getBoundingClientRect();
3452 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 || rect.width <= 0 || rect.height <= 0) continue;
3453 if (element.checkVisibility && !element.checkVisibility({{checkOpacity:true, checkVisibilityCSS:true}})) continue;
3454 let clipped = false;
3455 let visibleLeft = rect.left, visibleTop = rect.top, visibleRight = rect.right, visibleBottom = rect.bottom;
3456 for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {{
3457 const ancestorStyle = getComputedStyle(ancestor);
3458 if (ancestorStyle.display === 'none' || ancestorStyle.visibility === 'hidden' || Number(ancestorStyle.opacity) === 0) {{ clipped = true; break; }}
3459 if (/(hidden|clip)/.test(ancestorStyle.overflow + ancestorStyle.overflowX + ancestorStyle.overflowY)) {{
3460 const bounds = ancestor.getBoundingClientRect();
3461 visibleLeft = Math.max(visibleLeft, bounds.left); visibleTop = Math.max(visibleTop, bounds.top);
3462 visibleRight = Math.min(visibleRight, bounds.right); visibleBottom = Math.min(visibleBottom, bounds.bottom);
3463 if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {{ clipped = true; break; }}
3464 }}
3465 }}
3466 if (clipped) continue;
3467 const actual = (element.innerText || '').replace(/\s+/g, ' ').trim();
3468 if (actual !== wanted) continue;
3469 const candidate = element.closest('button,a,input,select,textarea,[role],[tabindex]') || element;
3470 if (matches.includes(candidate)) continue;
3471 for (let index = matches.length - 1; index >= 0; index--) {{
3472 if (matches[index].contains(candidate)) {{ matches.splice(index, 1); count--; }}
3473 }}
3474 count++;
3475 if (matches.length < {AMBIGUOUS_CANDIDATE_LIMIT}) matches.push(candidate);
3476 }}
3477 matches.glassCount = count;
3478 return matches;
3479 }})()"#
3480 ))
3481}
3482
3483pub(crate) fn visible_text_contains_expression(text: &str) -> BrowserResult<String> {
3484 let text = serde_json::to_string(text)?;
3485 Ok(format!(
3486 r#"(() => {{
3487 const wanted = {text};
3488 const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
3489 for (let node = walker.nextNode(); node; node = walker.nextNode()) {{
3490 if (!(node.nodeValue || '').includes(wanted)) continue;
3491 const element = node.parentElement;
3492 if (!element) continue;
3493 if (element.checkVisibility && !element.checkVisibility({{checkOpacity:true, checkVisibilityCSS:true}})) continue;
3494 const rect = element.getBoundingClientRect();
3495 if (rect.width <= 0 || rect.height <= 0) continue;
3496 let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom, hidden = false;
3497 for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {{
3498 const style = getComputedStyle(ancestor);
3499 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {{ hidden = true; break; }}
3500 if (/(hidden|clip)/.test(style.overflow + style.overflowX + style.overflowY)) {{
3501 const bounds = ancestor.getBoundingClientRect();
3502 left = Math.max(left, bounds.left); top = Math.max(top, bounds.top);
3503 right = Math.min(right, bounds.right); bottom = Math.min(bottom, bounds.bottom);
3504 if (right <= left || bottom <= top) {{ hidden = true; break; }}
3505 }}
3506 }}
3507 if (!hidden) return true;
3508 }}
3509 return false;
3510 }})()"#
3511 ))
3512}
3513
3514pub(crate) fn bounded_candidate_label(value: &str) -> String {
3515 if value.len() <= CANDIDATE_LABEL_MAX_BYTES {
3516 return value.to_string();
3517 }
3518 let mut end = CANDIDATE_LABEL_MAX_BYTES - "…".len();
3519 while !value.is_char_boundary(end) {
3520 end -= 1;
3521 }
3522 format!("{}…", &value[..end])
3523}
3524
3525pub(crate) fn validate_topology_id(value: &str) -> BrowserResult<()> {
3526 if value.is_empty() {
3527 return Err("topology ID cannot be empty".into());
3528 }
3529 if value.len() > TOPOLOGY_ID_MAX_BYTES {
3530 return Err(format!("topology ID exceeds {TOPOLOGY_ID_MAX_BYTES} UTF-8 bytes").into());
3531 }
3532 Ok(())
3533}
3534
3535pub(crate) fn bounded_topology_text(value: &str) -> String {
3536 if value.len() <= TOPOLOGY_TEXT_MAX_BYTES {
3537 return value.to_string();
3538 }
3539 let mut end = TOPOLOGY_TEXT_MAX_BYTES - "…".len();
3540 while !value.is_char_boundary(end) {
3541 end -= 1;
3542 }
3543 format!("{}…", &value[..end])
3544}
3545
3546pub(crate) fn collect_frames(
3547 frame_tree: &Value,
3548 parent_id: Option<&str>,
3549 active_frame_id: Option<&str>,
3550 frames: &mut Vec<FrameInfo>,
3551) -> BrowserResult<()> {
3552 if frames.len() == TOPOLOGY_MAX_FRAMES {
3553 return Err("frame limit exceeded".into());
3554 }
3555 let frame = frame_tree
3556 .get("frame")
3557 .ok_or("Page.getFrameTree returned a node without frame data")?;
3558 let id = frame["id"]
3559 .as_str()
3560 .ok_or("Page.getFrameTree returned a frame without an ID")?;
3561 validate_topology_id(id)?;
3562 frames.push(FrameInfo {
3563 id: id.to_string(),
3564 parent_id: parent_id.map(str::to_string),
3565 url: bounded_topology_text(frame["url"].as_str().unwrap_or_default()),
3566 active: active_frame_id == Some(id),
3567 out_of_process: false,
3568 });
3569 if let Some(children) = frame_tree["childFrames"].as_array() {
3570 for child in children {
3571 collect_frames(child, Some(id), active_frame_id, frames)?;
3572 }
3573 }
3574 Ok(())
3575}
3576
3577pub(crate) fn push_topology_event(topology: &mut TopologyRegistry, kind: &str, id: &str) -> u64 {
3578 topology.sequence = topology
3579 .sequence
3580 .checked_add(1)
3581 .expect("topology sequence exhausted");
3582 let sequence = topology.sequence;
3583 topology.events.push_back(TopologyEventSummary {
3584 sequence,
3585 kind: kind.to_string(),
3586 id: bounded_topology_id(id),
3587 });
3588 while topology.events.len() > TOPOLOGY_MAX_EVENTS {
3589 topology.events.pop_front();
3590 }
3591 sequence
3592}
3593
3594pub(crate) fn bounded_topology_id(value: &str) -> String {
3595 if value.len() <= TOPOLOGY_ID_MAX_BYTES {
3596 return value.to_string();
3597 }
3598 let mut end = TOPOLOGY_ID_MAX_BYTES - "…".len();
3599 while !value.is_char_boundary(end) {
3600 end -= 1;
3601 }
3602 format!("{}…", &value[..end])
3603}
3604
3605pub(crate) fn retained_optional_topology_id(value: Option<&str>) -> BrowserResult<Option<String>> {
3606 value
3607 .map(|id| {
3608 validate_topology_id(id)?;
3609 Ok(id.to_string())
3610 })
3611 .transpose()
3612}
3613
3614pub(crate) fn popup_typed_error(
3615 kind: PopupClickErrorKind,
3616 message: impl Into<String>,
3617) -> PopupClickError {
3618 let message = message.into();
3619 PopupClickError {
3620 kind,
3621 message: truncate_utf8_bytes(&message, POPUP_ERROR_MESSAGE_MAX_BYTES),
3622 }
3623}
3624
3625pub(crate) fn popup_witness_install_function() -> String {
3626 format!(
3627 r#"function() {{
3628 const element = this;
3629 const nativeAdd = EventTarget.prototype.addEventListener;
3630 const nativeRemove = EventTarget.prototype.removeEventListener;
3631 const nativeApply = Reflect.apply;
3632 const nativeSetTimeout = setTimeout;
3633 const nativeClearTimeout = clearTimeout;
3634 const state = {{fired:false, cleaned:false}};
3635 const listener = function(event) {{
3636 if (event.isTrusted === true && event.currentTarget === element) state.fired = true;
3637 }};
3638 let timer;
3639 const cleanup = function() {{
3640 if (state.cleaned) return true;
3641 state.cleaned = true;
3642 nativeApply(nativeRemove, element, ['click', listener, true]);
3643 nativeClearTimeout(timer);
3644 return true;
3645 }};
3646 Object.defineProperties(state, {{
3647 read: {{value:function(){{return state.fired;}}, enumerable:false}},
3648 cleanup: {{value:cleanup, enumerable:false}}
3649 }});
3650 nativeApply(nativeAdd, element, ['click', listener, {{capture:true, once:true}}]);
3651 timer = nativeSetTimeout(cleanup, {POPUP_WITNESS_LIFETIME_MS});
3652 return state;
3653 }}"#
3654 )
3655}
3656
3657pub(crate) async fn popup_witness_call<F>(future: F, step: &str) -> Result<Value, PopupClickError>
3658where
3659 F: std::future::Future<Output = Result<Value, crate::browser::cdp::CdpError>>,
3660{
3661 match tokio::time::timeout(POPUP_VERIFY_CALL_TIMEOUT, future).await {
3662 Ok(Ok(value)) => Ok(value),
3663 Ok(Err(error)) => Err(popup_typed_error(
3664 PopupClickErrorKind::WitnessMissing,
3665 format!("{step} failed: {error}"),
3666 )),
3667 Err(_) => Err(popup_typed_error(
3668 PopupClickErrorKind::WitnessMissing,
3669 format!("{step} exceeded its bounded deadline"),
3670 )),
3671 }
3672}
3673
3674pub(crate) async fn arm_popup_witness_owned(
3675 cdp: CdpClient,
3676 session_id: String,
3677 frame_id: String,
3678 backend_node_id: i64,
3679) -> Result<PopupWitnessGuard, PopupClickError> {
3680 let world = popup_witness_call(
3681 cdp.send_to_session(
3682 &session_id,
3683 "Page.createIsolatedWorld",
3684 Some(serde_json::json!({
3685 "frameId": frame_id,
3686 "worldName": "glass-popup-witness"
3687 })),
3688 ),
3689 "popup witness isolated world",
3690 )
3691 .await?;
3692 let context_id = world["executionContextId"].as_i64().ok_or_else(|| {
3693 popup_typed_error(
3694 PopupClickErrorKind::WitnessMissing,
3695 "popup witness isolated world returned no context ID",
3696 )
3697 })?;
3698 let resolved = popup_witness_call(
3699 cdp.send_to_session(
3700 &session_id,
3701 "DOM.resolveNode",
3702 Some(serde_json::json!({
3703 "backendNodeId": backend_node_id,
3704 "executionContextId": context_id
3705 })),
3706 ),
3707 "popup witness exact-node resolution",
3708 )
3709 .await?;
3710 let element_object_id = resolved["object"]["objectId"]
3711 .as_str()
3712 .ok_or_else(|| {
3713 popup_typed_error(
3714 PopupClickErrorKind::WitnessMissing,
3715 "popup witness exact-node resolution returned no object",
3716 )
3717 })?
3718 .to_string();
3719 let install = popup_witness_call(
3720 cdp.send_to_session(
3721 &session_id,
3722 "Runtime.callFunctionOn",
3723 Some(serde_json::json!({
3724 "objectId": element_object_id,
3725 "functionDeclaration": popup_witness_install_function(),
3726 "returnByValue": false,
3727 "awaitPromise": false
3728 })),
3729 ),
3730 "popup witness installation",
3731 )
3732 .await;
3733 let state_object_id = match install {
3734 Ok(value) => match value["result"]["objectId"].as_str() {
3735 Some(id) => id.to_string(),
3736 None => {
3737 let _ = tokio::time::timeout(
3738 POPUP_VERIFY_CALL_TIMEOUT,
3739 cdp.release_object_for_session(&session_id, &element_object_id),
3740 )
3741 .await;
3742 return Err(popup_typed_error(
3743 PopupClickErrorKind::WitnessMissing,
3744 "popup witness installation returned no private state",
3745 ));
3746 }
3747 },
3748 Err(error) => {
3749 let _ = tokio::time::timeout(
3750 POPUP_VERIFY_CALL_TIMEOUT,
3751 cdp.release_object_for_session(&session_id, &element_object_id),
3752 )
3753 .await;
3754 return Err(error);
3755 }
3756 };
3757 Ok(PopupWitnessGuard {
3758 cdp,
3759 session_id,
3760 state_object_id,
3761 element_object_id,
3762 armed: true,
3763 })
3764}
3765
3766pub(crate) async fn cleanup_popup_witness(
3767 cdp: &CdpClient,
3768 session_id: &str,
3769 state_object_id: &str,
3770 element_object_id: &str,
3771) -> BrowserResult<()> {
3772 let cleanup = tokio::time::timeout(
3773 POPUP_VERIFY_CALL_TIMEOUT,
3774 cdp.send_to_session(
3775 session_id,
3776 "Runtime.callFunctionOn",
3777 Some(serde_json::json!({
3778 "objectId": state_object_id,
3779 "functionDeclaration": "function(){return this.cleanup();}",
3780 "returnByValue": true,
3781 "awaitPromise": false
3782 })),
3783 ),
3784 )
3785 .await;
3786 let release_state = tokio::time::timeout(
3787 POPUP_VERIFY_CALL_TIMEOUT,
3788 cdp.release_object_for_session(session_id, state_object_id),
3789 )
3790 .await;
3791 let release_element = tokio::time::timeout(
3792 POPUP_VERIFY_CALL_TIMEOUT,
3793 cdp.release_object_for_session(session_id, element_object_id),
3794 )
3795 .await;
3796 if !matches!(cleanup, Ok(Ok(_)))
3797 || !matches!(release_state, Ok(Ok(_)))
3798 || !matches!(release_element, Ok(Ok(_)))
3799 {
3800 return Err(popup_error(
3801 PopupClickErrorKind::PopupUnreadable,
3802 "popup witness remote-object cleanup failed",
3803 ));
3804 }
3805 Ok(())
3806}
3807
3808pub(crate) fn popup_error(kind: PopupClickErrorKind, message: impl Into<String>) -> Box<dyn Error> {
3809 Box::new(popup_typed_error(kind, message))
3810}
3811
3812pub(crate) fn download_error(kind: DownloadErrorKind, message: impl Into<String>) -> DownloadError {
3813 let message = message.into();
3814 DownloadError {
3815 kind,
3816 message: truncate_utf8_bytes(&message, POPUP_ERROR_MESSAGE_MAX_BYTES),
3817 }
3818}
3819
3820pub(crate) async fn target_browser_context_id(
3821 cdp: &CdpClient,
3822 target_id: &str,
3823 required: bool,
3824) -> Result<Option<String>, DownloadError> {
3825 let response = cdp
3826 .send_browser(
3827 "Target.getTargetInfo",
3828 Some(serde_json::json!({"targetId": target_id})),
3829 )
3830 .await
3831 .map_err(|error| {
3832 download_error(
3833 DownloadErrorKind::AuthorizationFailed,
3834 format!("download target context lookup failed: {error}"),
3835 )
3836 })?;
3837 let target_info = response["targetInfo"].as_object().ok_or_else(|| {
3838 download_error(
3839 DownloadErrorKind::AuthorizationFailed,
3840 "download target context lookup returned no targetInfo",
3841 )
3842 })?;
3843 if target_info.get("targetId").and_then(Value::as_str) != Some(target_id) {
3844 return Err(download_error(
3845 DownloadErrorKind::AuthorizationFailed,
3846 "download target context lookup returned a mismatched target ID",
3847 ));
3848 }
3849 let context_id = target_info
3850 .get("browserContextId")
3851 .and_then(Value::as_str)
3852 .map(str::to_string);
3853 if required && context_id.is_none() {
3854 return Err(download_error(
3855 DownloadErrorKind::AuthorizationFailed,
3856 "download target context lookup returned no browser context ID",
3857 ));
3858 }
3859 if let Some(context_id) = context_id.as_deref() {
3860 validate_topology_id(context_id).map_err(|error| {
3861 download_error(
3862 DownloadErrorKind::AuthorizationFailed,
3863 format!("download target returned an invalid browser context ID: {error}"),
3864 )
3865 })?;
3866 }
3867 Ok(context_id)
3868}
3869
3870pub(crate) fn use_page_download_compatibility(owned: bool, command_line_incognito: bool) -> bool {
3871 owned && command_line_incognito
3872}
3873
3874pub(crate) fn protocol_result_summary(result: Result<(), crate::browser::cdp::CdpError>) -> String {
3875 result
3876 .map(|_| "completed".to_string())
3877 .unwrap_or_else(|error| error.to_string())
3878}
3879
3880pub(crate) fn assess_popup_topology(
3881 snapshot: &PopupTopologySnapshot,
3882 topology: &TopologyRegistry,
3883 witnessed: bool,
3884) -> Result<PopupCandidate, PopupClickError> {
3885 if !witnessed {
3886 return Err(popup_typed_error(
3887 PopupClickErrorKind::WitnessMissing,
3888 "trusted-click witness was not observed",
3889 ));
3890 }
3891 if topology.event_loss_count != snapshot.event_loss_count {
3892 return Err(popup_typed_error(
3893 PopupClickErrorKind::TopologyLagged,
3894 "topology event stream lagged after mouseReleased",
3895 ));
3896 }
3897
3898 let later_targets = topology.targets.iter().filter_map(|target| {
3899 let sequence = *topology.target_sequences.get(&target.id)?;
3900 (!snapshot.preexisting_target_ids.contains(&target.id) && sequence > snapshot.sequence)
3901 .then_some((target, sequence))
3902 });
3903 let mut matching = Vec::new();
3904 let mut nonmatching_count = 0usize;
3905 for (target, sequence) in later_targets {
3906 if target.opener_id.as_deref() == Some(snapshot.original_target_id.as_str()) {
3907 matching.push(PopupCandidate {
3908 target: target.clone(),
3909 observed_sequence: sequence,
3910 });
3911 } else {
3912 nonmatching_count += 1;
3913 }
3914 }
3915 if matching.len() > 1 {
3916 return Err(popup_typed_error(
3917 PopupClickErrorKind::PopupAmbiguous,
3918 format!(
3919 "trusted click produced {} opener-matching page targets",
3920 matching.len()
3921 ),
3922 ));
3923 }
3924 if let Some(candidate) = matching.pop() {
3925 return Ok(candidate);
3926 }
3927 if topology.destroyed_targets.iter().any(|destroyed| {
3928 destroyed.observed_sequence > snapshot.sequence
3929 && !snapshot
3930 .preexisting_target_ids
3931 .contains(&destroyed.target.id)
3932 && destroyed.target.opener_id.as_deref() == Some(snapshot.original_target_id.as_str())
3933 }) {
3934 return Err(popup_typed_error(
3935 PopupClickErrorKind::PopupDestroyed,
3936 "the opener-matching popup was destroyed before verification",
3937 ));
3938 }
3939 if nonmatching_count > 0 {
3940 return Err(popup_typed_error(
3941 PopupClickErrorKind::PopupOpenerMismatch,
3942 "later page targets did not name the original target as opener",
3943 ));
3944 }
3945 Err(popup_typed_error(
3946 PopupClickErrorKind::PopupMissing,
3947 "no later opener-matching popup was observed",
3948 ))
3949}
3950
3951pub(crate) async fn popup_verification_call<F>(future: F, step: &str) -> BrowserResult<Value>
3952where
3953 F: std::future::Future<Output = Result<Value, crate::browser::cdp::CdpError>>,
3954{
3955 match tokio::time::timeout(POPUP_VERIFY_CALL_TIMEOUT, future).await {
3956 Ok(Ok(value)) => Ok(value),
3957 Ok(Err(error)) => Err(popup_error(
3958 PopupClickErrorKind::PopupUnreadable,
3959 format!("{step} failed: {error}"),
3960 )),
3961 Err(_) => Err(popup_error(
3962 PopupClickErrorKind::PopupUnreadable,
3963 format!("{step} exceeded its bounded deadline"),
3964 )),
3965 }
3966}
3967
3968pub(crate) fn apply_topology_event(
3971 topology: &mut TopologyRegistry,
3972 event: &CdpEventWithParams,
3973) -> bool {
3974 match event.method.as_str() {
3975 "Target.targetCreated" | "Target.targetInfoChanged" => {
3976 let info = &event.params["targetInfo"];
3977 if info["type"].as_str() != Some("page") {
3978 return false;
3979 }
3980 let Some(id) = info["targetId"].as_str() else {
3981 return false;
3982 };
3983 if validate_topology_id(id).is_err() {
3984 push_topology_event(topology, "rejected-target", id);
3985 return false;
3986 }
3987 let Ok(opener_id) = retained_optional_topology_id(info["openerId"].as_str()) else {
3988 push_topology_event(topology, "rejected-target-opener", id);
3989 return false;
3990 };
3991 let target = PageTargetInfo {
3992 id: id.to_string(),
3993 url: bounded_topology_text(info["url"].as_str().unwrap_or_default()),
3994 title: bounded_topology_text(info["title"].as_str().unwrap_or_default()),
3995 opener_id,
3996 active: topology.active_target_id.as_deref() == Some(id),
3997 };
3998 if let Some(existing) = topology.targets.iter_mut().find(|target| target.id == id) {
3999 *existing = target;
4000 } else if topology.targets.len() < TOPOLOGY_MAX_TARGETS {
4001 topology.targets.push(target);
4002 } else {
4003 push_topology_event(topology, "rejected-target-budget", id);
4004 return false;
4005 }
4006 let sequence = push_topology_event(topology, "target-updated", id);
4007 topology.target_sequences.insert(id.to_string(), sequence);
4008 }
4009 "Target.targetDestroyed" | "Target.targetCrashed" => {
4010 let Some(id) = event.params["targetId"].as_str() else {
4011 return false;
4012 };
4013 let removed = topology
4014 .targets
4015 .iter()
4016 .find(|target| target.id == id)
4017 .cloned();
4018 topology.targets.retain(|target| target.id != id);
4019 topology.target_sequences.remove(id);
4020 let sequence = push_topology_event(topology, event.method.as_str(), id);
4021 if let Some(target) = removed {
4022 topology.destroyed_targets.push_back(DestroyedPageTarget {
4023 target,
4024 observed_sequence: sequence,
4025 });
4026 while topology.destroyed_targets.len() > TOPOLOGY_MAX_EVENTS {
4027 topology.destroyed_targets.pop_front();
4028 }
4029 }
4030 if topology.active_target_id.as_deref() == Some(id) {
4031 topology.active_target_id = None;
4032 topology.active_target_session_id = None;
4033 topology.active_session_id = None;
4034 topology.active_frame_id = None;
4035 topology.frames.clear();
4036 topology.frame_sessions.clear();
4037 topology.frame_parents.clear();
4038 return true;
4039 }
4040 }
4041 "Target.detachedFromTarget" => {
4042 let Some(session_id) = event.params["sessionId"].as_str() else {
4043 return false;
4044 };
4045 push_topology_event(topology, "Target.detachedFromTarget", session_id);
4046 if topology.active_target_session_id.as_deref() == Some(session_id) {
4047 topology.active_target_id = None;
4048 topology.active_target_session_id = None;
4049 topology.active_session_id = None;
4050 topology.active_frame_id = None;
4051 topology.frames.clear();
4052 return true;
4053 }
4054 let active_session_detached = topology.active_session_id.as_deref() == Some(session_id);
4055 topology
4056 .frame_sessions
4057 .retain(|_, attached_session| attached_session != session_id);
4058 if active_session_detached {
4059 topology.active_frame_id = None;
4060 topology.active_session_id = topology.active_target_session_id.clone();
4061 }
4062 }
4063 "Target.attachedToTarget" => {
4064 let info = &event.params["targetInfo"];
4065 if info["type"].as_str() != Some("iframe") {
4066 return false;
4067 }
4068 let (Some(frame_id), Some(session_id)) = (
4069 info["targetId"].as_str(),
4070 event.params["sessionId"].as_str(),
4071 ) else {
4072 return false;
4073 };
4074 if validate_topology_id(frame_id).is_err() || validate_topology_id(session_id).is_err()
4075 {
4076 return false;
4077 }
4078 if topology.frame_sessions.len() < TOPOLOGY_MAX_FRAMES {
4079 topology
4080 .frame_sessions
4081 .insert(frame_id.to_string(), session_id.to_string());
4082 push_topology_event(topology, "Target.attachedToTarget", frame_id);
4083 }
4084 }
4085 "Page.frameAttached" | "Page.frameNavigated" | "Page.frameDetached" => {
4086 let event_session = event.session_id.as_deref();
4087 let belongs_to_topology = event_session == topology.active_target_session_id.as_deref()
4088 || event_session.is_some_and(|session_id| {
4089 topology
4090 .frame_sessions
4091 .values()
4092 .any(|attached| attached == session_id)
4093 });
4094 if !belongs_to_topology {
4095 return false;
4096 }
4097 let id = event.params["frameId"]
4098 .as_str()
4099 .or_else(|| event.params["frame"]["id"].as_str());
4100 if let Some(id) = id
4101 && validate_topology_id(id).is_ok()
4102 {
4103 push_topology_event(topology, event.method.as_str(), id);
4104 if event.method == "Page.frameAttached"
4105 && let Some(parent_id) = event.params["parentFrameId"].as_str()
4106 && validate_topology_id(parent_id).is_ok()
4107 {
4108 topology
4109 .frame_parents
4110 .insert(id.to_string(), parent_id.to_string());
4111 }
4112 if event.method == "Page.frameDetached" {
4113 topology.frame_parents.remove(id);
4114 }
4115 }
4116 let selected_was_affected = id.is_some_and(|changed_id| {
4117 topology.active_frame_id.as_deref() == Some(changed_id)
4118 || frame_is_descendant_of(
4119 &topology.frames,
4120 topology.active_frame_id.as_deref(),
4121 changed_id,
4122 )
4123 });
4124 let selected_is_main = topology
4125 .active_frame_id
4126 .as_deref()
4127 .and_then(|selected| topology.frames.iter().find(|frame| frame.id == selected))
4128 .is_some_and(|frame| frame.parent_id.is_none());
4129 topology.frames.clear();
4130 if selected_was_affected
4131 && matches!(
4132 event.method.as_str(),
4133 "Page.frameNavigated" | "Page.frameDetached"
4134 )
4135 && !(event.method == "Page.frameNavigated" && selected_is_main)
4136 {
4137 topology.active_frame_id = None;
4138 }
4139 }
4140 "Page.javascriptDialogOpening" => {
4141 let dialog_type = event.params["type"].as_str().unwrap_or("alert").to_string();
4142 let message =
4143 bounded_topology_text(event.params["message"].as_str().unwrap_or_default());
4144 let default_value = event.params["defaultPrompt"].as_str().map(String::from);
4145 let url = bounded_topology_text(event.params["url"].as_str().unwrap_or_default());
4146 topology.pending_dialog = Some(PendingDialog {
4147 dialog_type,
4148 message,
4149 default_value,
4150 url,
4151 });
4152 push_topology_event(topology, "Page.javascriptDialogOpening", "dialog");
4153 }
4154 "Page.javascriptDialogClosed" => {
4155 topology.pending_dialog = None;
4156 push_topology_event(topology, "Page.javascriptDialogClosed", "dialog");
4157 }
4158 _ => {}
4159 }
4160 false
4161}
4162
4163pub(crate) fn frame_is_descendant_of(
4164 frames: &[FrameInfo],
4165 selected: Option<&str>,
4166 ancestor: &str,
4167) -> bool {
4168 let Some(mut current) = selected else {
4169 return false;
4170 };
4171 while let Some(frame) = frames.iter().find(|frame| frame.id == current) {
4172 let Some(parent) = frame.parent_id.as_deref() else {
4173 return false;
4174 };
4175 if parent == ancestor {
4176 return true;
4177 }
4178 current = parent;
4179 }
4180 false
4181}
4182
4183pub(crate) async fn resync_topology(
4184 cdp: &CdpClient,
4185 registry: &Arc<Mutex<TopologyRegistry>>,
4186) -> BrowserResult<()> {
4187 let raw = cdp.send_browser("Target.getTargets", None).await?;
4188 let (active_target, active_frame, target_session) = {
4189 let topology = registry.lock().await;
4190 (
4191 topology.active_target_id.clone(),
4192 topology.active_frame_id.clone(),
4193 topology.active_target_session_id.clone(),
4194 )
4195 };
4196 let mut targets = Vec::new();
4197 for info in raw["targetInfos"].as_array().into_iter().flatten() {
4198 if info["type"].as_str() != Some("page") {
4199 continue;
4200 }
4201 let id = info["targetId"]
4202 .as_str()
4203 .ok_or("Target.getTargets returned a page without an ID")?;
4204 validate_topology_id(id)?;
4205 if targets.len() == TOPOLOGY_MAX_TARGETS {
4206 return Err(TopologyError::new(
4207 TopologyErrorKind::BudgetExceeded,
4208 "page target limit exceeded during topology resync; consider closing unused targets",
4209 )
4210 .into());
4211 }
4212 targets.push(PageTargetInfo {
4213 id: id.to_string(),
4214 url: bounded_topology_text(info["url"].as_str().unwrap_or_default()),
4215 title: bounded_topology_text(info["title"].as_str().unwrap_or_default()),
4216 opener_id: retained_optional_topology_id(info["openerId"].as_str())?,
4217 active: active_target.as_deref() == Some(id),
4218 });
4219 }
4220 let mut frames = Vec::new();
4221 if let Some(target_session) = target_session {
4222 let raw = cdp
4223 .send_to_session(&target_session, "Page.getFrameTree", None)
4224 .await?;
4225 collect_frames(
4226 &raw["frameTree"],
4227 None,
4228 active_frame.as_deref(),
4229 &mut frames,
4230 )?;
4231 }
4232 let mut topology = registry.lock().await;
4233 topology.targets = targets;
4234 topology.frames = frames;
4235 let sequence = push_topology_event(&mut topology, "resynchronized", "topology");
4236 topology.target_sequences = topology
4237 .targets
4238 .iter()
4239 .map(|target| (target.id.clone(), sequence))
4240 .collect();
4241 Ok(())
4242}
4243
4244pub(crate) fn actionability_reason(reason: &str) -> TargetActionabilityReason {
4245 match reason {
4246 "detached" => TargetActionabilityReason::Detached,
4247 "not_visible" => TargetActionabilityReason::NotVisible,
4248 "disabled" => TargetActionabilityReason::Disabled,
4249 "unstable_geometry" => TargetActionabilityReason::UnstableGeometry,
4250 "outside_viewport" => TargetActionabilityReason::OutsideViewport,
4251 "hit_test_blocked" => TargetActionabilityReason::HitTestBlocked,
4252 _ => TargetActionabilityReason::VerificationFailed,
4253 }
4254}
4255
4256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4257pub(crate) struct RevisionedElementReference {
4258 pub(crate) revision: u64,
4259 pub(crate) backend_dom_node_id: i64,
4260}
4261
4262pub(crate) fn parse_revisioned_reference(
4268 value: &str,
4269) -> BrowserResult<Option<RevisionedElementReference>> {
4270 let Some(rest) = value.strip_prefix('r') else {
4271 return Ok(None);
4272 };
4273 let Some((revision, backend_dom_node_id)) = rest.split_once(":b") else {
4274 return Ok(None);
4275 };
4276 if revision.is_empty() || backend_dom_node_id.is_empty() {
4277 return Err(format!("invalid revisioned element reference: {value}").into());
4278 }
4279 let revision = revision
4280 .parse::<u64>()
4281 .map_err(|_| format!("invalid element reference revision: {value}"))?;
4282 let backend_dom_node_id = backend_dom_node_id
4283 .parse::<i64>()
4284 .ok()
4285 .filter(|id| *id > 0)
4286 .ok_or_else(|| format!("invalid backend node ID in element reference: {value}"))?;
4287 Ok(Some(RevisionedElementReference {
4288 revision,
4289 backend_dom_node_id,
4290 }))
4291}
4292
4293pub(crate) fn runtime_value(raw: &Value) -> BrowserResult<Value> {
4294 if let Some(exception) = raw.get("exceptionDetails") {
4295 return Err(format!("JavaScript evaluation failed: {exception}").into());
4296 }
4297 Ok(raw["result"]["value"].clone())
4298}
4299
4300pub(crate) async fn wait_for_ws_url(port: u16, target_id: Option<&str>) -> BrowserResult<String> {
4301 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
4302 loop {
4303 match crate::browser::chrome::get_owned_ws_url(port, target_id).await {
4304 Ok(url) => return Ok(url),
4305 Err(error)
4306 if error.to_string().starts_with("No page target")
4307 && tokio::time::Instant::now() < deadline =>
4308 {
4309 tracing::debug!(%error, "waiting for Chrome page target");
4310 tokio::time::sleep(Duration::from_millis(100)).await;
4311 }
4312 Err(error) => return Err(error),
4313 }
4314 }
4315}
4316
4317pub fn normalize_url(url: &str) -> String {
4320 let url = url.trim();
4321 if url.starts_with("http://")
4322 || url.starts_with("https://")
4323 || url.starts_with("about:")
4324 || url.starts_with("file:")
4325 || url.starts_with("data:")
4326 {
4327 url.to_string()
4328 } else {
4329 format!("https://{url}")
4330 }
4331}
4332
4333pub(crate) async fn disable_network_for(
4334 cdp: &CdpClient,
4335 session_id: Option<&str>,
4336) -> BrowserResult<()> {
4337 match session_id {
4338 Some(session_id) => {
4339 cdp.send_to_session(session_id, "Network.disable", None)
4340 .await?
4341 }
4342 None => cdp.send("Network.disable", None).await?,
4343 };
4344 Ok(())
4345}