Skip to main content

car_browser/
chromium.rs

1//! Headless Chromium backend via chromiumoxide.
2//!
3//! Implements `BrowserBackend` using Chrome DevTools Protocol (CDP).
4//! Requires a Chromium/Chrome binary on the system.
5
6use async_trait::async_trait;
7use chromiumoxide::browser::{Browser, BrowserConfig};
8use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
9use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams, GetBoxModelParams};
10use chromiumoxide::cdp::browser_protocol::input::{
11    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
12    MouseButton,
13};
14use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
15use chromiumoxide::Page;
16use futures::StreamExt;
17use std::collections::HashMap;
18use std::sync::Arc;
19use std::sync::Mutex as StdMutex;
20use std::time::Duration;
21use tokio::sync::RwLock;
22use tokio::task::JoinHandle;
23use tokio::time::timeout;
24
25use crate::backend::{BrowserBackend, BrowserError};
26use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
27
28/// Cached mapping from ax_N node IDs to their CDP backend DOM node IDs.
29/// Populated during `get_accessibility_tree()`, consumed by `click_element()`
30/// and `focus_element()`.
31type AxNodeCache = HashMap<String, BackendNodeId>;
32
33/// Headless Chromium browser backend.
34pub struct ChromiumBackend {
35    page: Arc<RwLock<Option<Page>>>,
36    _browser: Arc<RwLock<Option<Browser>>>,
37    viewport_width: u32,
38    viewport_height: u32,
39    /// Cached URL, updated on navigate() and get_accessibility_tree().
40    /// Uses std::sync::RwLock so get_current_url() can be synchronous.
41    cached_url: std::sync::RwLock<String>,
42    /// Cached mapping from ax_N IDs to BackendNodeId, populated by
43    /// get_accessibility_tree(). Used by click_element/focus_element/type_into_element
44    /// to resolve ax_N to real DOM coordinates.
45    ax_node_cache: std::sync::RwLock<AxNodeCache>,
46    /// Per-instance Chromium profile directory. Held so the
47    /// directory survives until ChromiumBackend is dropped, then
48    /// auto-cleans (TempDir runs `remove_dir_all` in its Drop).
49    /// `None` when the caller passed an explicit profile dir via
50    /// `CAR_BROWSER_PROFILE_DIR` — caller owns lifecycle in that case.
51    /// See #148: under Playwright workers running in parallel, the
52    /// chromiumoxide default profile dir caused SingletonLock
53    /// contention; per-instance dirs eliminate the collision.
54    _profile_dir: Option<tempfile::TempDir>,
55    /// PID of the Chrome subprocess. Captured at launch so `Drop`
56    /// can synchronously SIGKILL it without needing the tokio
57    /// runtime. chromiumoxide relies on tokio's `kill_on_drop`,
58    /// which only fires if the runtime is still alive when the
59    /// `Browser` drops — on process panic/abort the Chrome
60    /// subprocess is reparented to PID 1 and leaks. We bypass that
61    /// by remembering the PID ourselves.
62    chrome_pid: Option<u32>,
63    /// Handle to the spawned tokio task that drains chromiumoxide's
64    /// CDP event stream. Aborted in `shutdown()` and `Drop` so the
65    /// task doesn't outlive the backend (it holds the CDP channel,
66    /// which keeps the chromiumoxide `Browser` from quiescing).
67    handler_task: StdMutex<Option<JoinHandle<()>>>,
68}
69
70/// True iff `url` is on the same (scheme, host, port) as `origin`.
71///
72/// Used to enforce origin-locality for `set_local_storage`: localStorage is
73/// origin-scoped in the browser, so setting items from the wrong page would
74/// either fail silently or mutate the wrong origin's state. We require the
75/// caller to navigate to the origin first.
76fn current_origin_matches(url: &str, origin: &str) -> bool {
77    // Empty or `about:blank` is allowed — treat as a pre-page state where
78    // localStorage operations will be attached to the first real
79    // navigation. Chromium's about:blank has no persistent storage so the
80    // `evaluate` call will simply set localStorage on the next real page.
81    if url.is_empty() || url.starts_with("about:") {
82        return true;
83    }
84    let extract = |s: &str| {
85        let (scheme, rest) = s.split_once("://")?;
86        let host_part = rest.split('/').next().unwrap_or("");
87        Some(format!("{}://{}", scheme, host_part))
88    };
89    match (extract(url), extract(origin)) {
90        (Some(a), Some(b)) => a == b,
91        _ => false,
92    }
93}
94
95/// Convert our `Modifier` enum to CDP modifier bitmask.
96/// CDP defines: Alt=1, Ctrl=2, Meta/Command=4, Shift=8.
97fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
98    let mut flags: i64 = 0;
99    for m in modifiers {
100        flags |= match m {
101            Modifier::Alt => 1,
102            Modifier::Control => 2,
103            Modifier::Meta => 4,
104            Modifier::Shift => 8,
105        };
106    }
107    flags
108}
109
110/// Options for launching a `ChromiumBackend`.
111///
112/// `headless = false` shows a real visible Chromium window — intended for
113/// interactive flows like first-time authentication (LinkedIn, OAuth, SSO)
114/// where a human needs to complete sign-in / 2FA / captcha before the rest
115/// of the script runs headless against the persisted cookies.
116#[derive(Debug, Clone)]
117pub struct LaunchOptions {
118    pub width: u32,
119    pub height: u32,
120    pub headless: bool,
121    /// Extra command-line flags appended to Chromium's argv at
122    /// launch. Use cases include the Google Meet bot (#112) which
123    /// needs `--use-fake-ui-for-media-stream`,
124    /// `--autoplay-policy=no-user-gesture-required`, and the
125    /// container-friendly trio (`--no-sandbox`,
126    /// `--disable-dev-shm-usage`, `--disable-setuid-sandbox`).
127    /// Flags are passed verbatim — callers responsible for the
128    /// correctness of what they append.
129    pub extra_args: Vec<String>,
130}
131
132impl Default for LaunchOptions {
133    fn default() -> Self {
134        Self {
135            width: 1280,
136            height: 720,
137            headless: true,
138            extra_args: Vec::new(),
139        }
140    }
141}
142
143impl ChromiumBackend {
144    /// Launch a new headless Chromium instance.
145    pub async fn launch() -> Result<Self, BrowserError> {
146        Self::launch_with_viewport(1280, 720).await
147    }
148
149    /// Launch with specific viewport dimensions (headless).
150    pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
151        Self::launch_with_options(LaunchOptions {
152            width,
153            height,
154            headless: true,
155            extra_args: Vec::new(),
156        })
157        .await
158    }
159
160    /// Launch with full options, including headless/headed toggle.
161    ///
162    /// `headless = true` uses Chromium's new headless mode (`--headless=new`);
163    /// `headless = false` shows a visible window (for interactive auth etc.).
164    pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
165        let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
166        builder = if opts.headless {
167            builder.new_headless_mode()
168        } else {
169            builder.with_head()
170        };
171        if !opts.extra_args.is_empty() {
172            // chromiumoxide's BrowserConfig::builder().args takes
173            // an `IntoIterator<Item = impl Into<String>>` and
174            // appends each verbatim to Chromium's argv.
175            builder = builder.args(opts.extra_args.iter().map(String::as_str));
176        }
177
178        // Per-instance Chromium profile directory.
179        //
180        // chromiumoxide defaults to a fixed path under $TMPDIR;
181        // when the same default is reused by N parallel processes
182        // (e.g. Playwright workers each constructing a CarRuntime),
183        // they contend over Chromium's `SingletonLock` and the
184        // losers either silently get a wrong-port DevTools handshake
185        // or hang on navigate (#148).
186        //
187        // Each ChromiumBackend now gets its own tempdir, scoped to
188        // its lifetime. Callers who *want* persistence (cookies +
189        // localStorage between runs) can set CAR_BROWSER_PROFILE_DIR
190        // and accept they're back on the hook for parallel-launch
191        // coordination.
192        let (profile_dir, profile_handle) = match std::env::var("CAR_BROWSER_PROFILE_DIR") {
193            Ok(path) if !path.is_empty() => (std::path::PathBuf::from(path), None),
194            _ => {
195                let td = tempfile::Builder::new()
196                    .prefix("car-browser-profile-")
197                    .tempdir()
198                    .map_err(|e| {
199                        BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
200                    })?;
201                (td.path().to_path_buf(), Some(td))
202            }
203        };
204        builder = builder.user_data_dir(&profile_dir);
205
206        let config = builder
207            .build()
208            .map_err(|e| BrowserError::NotAvailable(format!("Config error: {}", e)))?;
209
210        let (mut browser, mut handler) = Browser::launch(config)
211            .await
212            .map_err(|e| BrowserError::NotAvailable(format!("Failed to launch Chrome: {}", e)))?;
213
214        // Capture the Chrome subprocess PID up front. `get_mut_child`
215        // returns the underlying tokio Child; its `id()` is `Some`
216        // until the process is reaped. We keep this so `Drop` can
217        // SIGKILL synchronously without awaiting (see field docs).
218        let chrome_pid = browser
219            .get_mut_child()
220            .and_then(|c| c.inner.id());
221
222        // Spawn the CDP event handler. We hold the JoinHandle so
223        // shutdown()/Drop can abort it — otherwise it lives for the
224        // process lifetime and keeps the CDP channel open, which
225        // prevents chromiumoxide from cleanly tearing down the
226        // Browser when callers expect `_browser.take()` to suffice.
227        let handler_task = tokio::spawn(async move {
228            while let Some(_event) = handler.next().await {}
229        });
230
231        let page = browser
232            .new_page("about:blank")
233            .await
234            .map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;
235
236        Ok(Self {
237            page: Arc::new(RwLock::new(Some(page))),
238            _browser: Arc::new(RwLock::new(Some(browser))),
239            viewport_width: opts.width,
240            viewport_height: opts.height,
241            cached_url: std::sync::RwLock::new("about:blank".to_string()),
242            ax_node_cache: std::sync::RwLock::new(HashMap::new()),
243            _profile_dir: profile_handle,
244            chrome_pid,
245            handler_task: StdMutex::new(Some(handler_task)),
246        })
247    }
248
249    /// OS process ID of the spawned Chrome subprocess, if known.
250    ///
251    /// Returns `None` when chromiumoxide did not spawn the process
252    /// itself (e.g. attached to an existing browser, which we do
253    /// not currently do but the API allows). Stable across the
254    /// lifetime of this backend — Chrome is not respawned on its
255    /// own — so callers can use it to assert cleanup in tests.
256    pub fn chrome_pid(&self) -> Option<u32> {
257        self.chrome_pid
258    }
259
260    async fn get_page(&self) -> Result<Page, BrowserError> {
261        self.page
262            .read()
263            .await
264            .clone()
265            .ok_or(BrowserError::NotAvailable("Page closed".into()))
266    }
267
268    /// Update the cached URL by querying the page asynchronously.
269    async fn refresh_cached_url(&self) {
270        if let Ok(page) = self.get_page().await {
271            if let Ok(Some(url)) = page.url().await {
272                if let Ok(mut cached) = self.cached_url.write() {
273                    *cached = url;
274                }
275            }
276        }
277    }
278
279    /// Look up the BackendNodeId for an ax_N node ID from the cache.
280    fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
281        let cache = self.ax_node_cache.read().map_err(|e| {
282            BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
283        })?;
284        cache.get(node_id).copied().ok_or_else(|| {
285            BrowserError::ElementNotFound(format!(
286                "No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
287                node_id
288            ))
289        })
290    }
291
292    /// Get the bounding box center for a BackendNodeId via CDP DOM.getBoxModel.
293    async fn get_element_center(
294        &self,
295        backend_node_id: BackendNodeId,
296    ) -> Result<(f64, f64), BrowserError> {
297        let page = self.get_page().await?;
298        let params = GetBoxModelParams::builder()
299            .backend_node_id(backend_node_id)
300            .build();
301        let result = page
302            .execute(params)
303            .await
304            .map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;
305
306        // The content quad is 8 floats: [x1,y1, x2,y2, x3,y3, x4,y4]
307        let quad = result.result.model.content.inner();
308        if quad.len() < 8 {
309            return Err(BrowserError::PlatformInternal(
310                "Content quad has fewer than 8 values".into(),
311            ));
312        }
313        // Compute center from the four corners
314        let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
315        let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
316        Ok((cx, cy))
317    }
318
319    /// Focus a DOM element by BackendNodeId via CDP DOM.focus.
320    async fn focus_by_backend_node_id(
321        &self,
322        backend_node_id: BackendNodeId,
323    ) -> Result<(), BrowserError> {
324        let page = self.get_page().await?;
325        let params = FocusParams::builder()
326            .backend_node_id(backend_node_id)
327            .build();
328        page.execute(params)
329            .await
330            .map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
331        Ok(())
332    }
333}
334
335#[async_trait]
336impl BrowserBackend for ChromiumBackend {
337    async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
338        let page = self.get_page().await?;
339        page.screenshot(
340            chromiumoxide::page::ScreenshotParams::builder()
341                .format(CaptureScreenshotFormat::Png)
342                .build(),
343        )
344        .await
345        .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
346    }
347
348    async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
349        let page = self.get_page().await?;
350        let result = page
351            .execute(GetFullAxTreeParams::default())
352            .await
353            .map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;
354
355        // Update cached URL while we have the page
356        self.refresh_cached_url().await;
357
358        let mut new_cache = AxNodeCache::new();
359
360        let mut nodes: Vec<A11yNode> = Vec::new();
361        for (i, n) in result.result.nodes.iter().enumerate() {
362            if n.ignored {
363                continue;
364            }
365
366            let ax_id = format!("ax_{}", i);
367
368            // Cache the BackendNodeId for later use by click_element/focus_element
369            if let Some(backend_id) = n.backend_dom_node_id {
370                new_cache.insert(ax_id.clone(), backend_id);
371            }
372
373            let role = n
374                .role
375                .as_ref()
376                .and_then(|r| r.value.as_ref())
377                .and_then(|v| v.as_str())
378                .unwrap_or("unknown")
379                .to_string();
380
381            let name = n
382                .name
383                .as_ref()
384                .and_then(|v| v.value.as_ref())
385                .and_then(|v| v.as_str())
386                .filter(|s| !s.is_empty())
387                .map(|s| s.to_string());
388
389            let value = n
390                .value
391                .as_ref()
392                .and_then(|v| v.value.as_ref())
393                .and_then(|v| v.as_str())
394                .filter(|s| !s.is_empty())
395                .map(|s| s.to_string());
396
397            let children: Vec<String> = n
398                .child_ids
399                .as_ref()
400                .map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
401                .unwrap_or_default();
402
403            // Resolve real bounds via DOM.getBoxModel if we have a backend node ID.
404            // Fall back to zero-sized bounds for nodes without a DOM backing (e.g. root).
405            let bounds = if let Some(backend_id) = n.backend_dom_node_id {
406                let bm_params = GetBoxModelParams::builder()
407                    .backend_node_id(backend_id)
408                    .build();
409                if let Ok(bm_result) = page.execute(bm_params).await {
410                    let quad = bm_result.result.model.content.inner();
411                    if quad.len() >= 8 {
412                        let x = quad[0];
413                        let y = quad[1];
414                        let width = quad[2] - quad[0];
415                        let height = quad[5] - quad[1];
416                        Bounds::new(x, y, width.max(0.0), height.max(0.0))
417                    } else {
418                        Bounds::new(0.0, 0.0, 0.0, 0.0)
419                    }
420                } else {
421                    Bounds::new(0.0, 0.0, 0.0, 0.0)
422                }
423            } else {
424                Bounds::new(0.0, 0.0, 0.0, 0.0)
425            };
426
427            nodes.push(A11yNode {
428                node_id: ax_id,
429                role,
430                name,
431                value,
432                bounds,
433                children,
434                focusable: true,
435                focused: false,
436                disabled: false,
437            });
438        }
439
440        // Update the shared cache
441        if let Ok(mut cache) = self.ax_node_cache.write() {
442            *cache = new_cache;
443        }
444
445        Ok(nodes)
446    }
447
448    fn get_viewport(&self) -> Result<Viewport, BrowserError> {
449        Ok(Viewport {
450            width: self.viewport_width,
451            height: self.viewport_height,
452            device_pixel_ratio: 1.0,
453        })
454    }
455
456    fn get_current_url(&self) -> Result<String, BrowserError> {
457        self.cached_url
458            .read()
459            .map(|url| url.clone())
460            .map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
461    }
462
463    async fn get_page_title(&self) -> Result<String, BrowserError> {
464        let page = self.get_page().await?;
465        page.evaluate("document.title")
466            .await
467            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
468            .into_value::<String>()
469            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))
470    }
471
472    async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
473        let page = self.get_page().await?;
474        page.goto(url)
475            .await
476            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
477        page.wait_for_navigation()
478            .await
479            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
480
481        // Update cached URL after navigation
482        if let Ok(mut cached) = self.cached_url.write() {
483            *cached = url.to_string();
484        }
485        // Also refresh from the page in case of redirects
486        self.refresh_cached_url().await;
487
488        Ok(())
489    }
490
491    async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
492        let page = self.get_page().await?;
493        page.execute(
494            DispatchMouseEventParams::builder()
495                .r#type(DispatchMouseEventType::MousePressed)
496                .x(x)
497                .y(y)
498                .button(MouseButton::Left)
499                .click_count(1)
500                .build()
501                .unwrap(),
502        )
503        .await
504        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
505
506        page.execute(
507            DispatchMouseEventParams::builder()
508                .r#type(DispatchMouseEventType::MouseReleased)
509                .x(x)
510                .y(y)
511                .button(MouseButton::Left)
512                .click_count(1)
513                .build()
514                .unwrap(),
515        )
516        .await
517        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
518
519        Ok(())
520    }
521
522    async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
523        let page = self.get_page().await?;
524        for ch in text.chars() {
525            page.execute(
526                DispatchKeyEventParams::builder()
527                    .r#type(DispatchKeyEventType::Char)
528                    .text(ch.to_string())
529                    .build()
530                    .unwrap(),
531            )
532            .await
533            .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
534        }
535        Ok(())
536    }
537
538    async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
539        let page = self.get_page().await?;
540        let cdp_modifiers = modifiers_to_cdp_flags(modifiers);
541
542        page.execute(
543            DispatchKeyEventParams::builder()
544                .r#type(DispatchKeyEventType::KeyDown)
545                .key(key.to_string())
546                .modifiers(cdp_modifiers)
547                .build()
548                .unwrap(),
549        )
550        .await
551        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
552
553        page.execute(
554            DispatchKeyEventParams::builder()
555                .r#type(DispatchKeyEventType::KeyUp)
556                .key(key.to_string())
557                .modifiers(cdp_modifiers)
558                .build()
559                .unwrap(),
560        )
561        .await
562        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
563
564        Ok(())
565    }
566
567    async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
568        let page = self.get_page().await?;
569        page.execute(
570            DispatchMouseEventParams::builder()
571                .r#type(DispatchMouseEventType::MouseWheel)
572                .x(self.viewport_width as f64 / 2.0)
573                .y(self.viewport_height as f64 / 2.0)
574                .delta_x(0.0)
575                .delta_y(delta_y as f64)
576                .build()
577                .unwrap(),
578        )
579        .await
580        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
581        Ok(())
582    }
583
584    async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
585        let backend_node_id = self.resolve_backend_node_id(node_id)?;
586        let (cx, cy) = self.get_element_center(backend_node_id).await?;
587        self.inject_click(cx, cy).await
588    }
589
590    async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
591        let backend_node_id = self.resolve_backend_node_id(node_id)?;
592        self.focus_by_backend_node_id(backend_node_id).await?;
593        self.inject_text(text).await
594    }
595
596    async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
597        let backend_node_id = self.resolve_backend_node_id(node_id)?;
598        self.focus_by_backend_node_id(backend_node_id).await
599    }
600
601    async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
602        let page = self.get_page().await?;
603        let state = page
604            .evaluate("document.readyState")
605            .await
606            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
607            .into_value::<String>()
608            .unwrap_or_default();
609        Ok(state == "complete")
610    }
611
612    async fn wait_until(
613        &self,
614        condition: &WaitCondition,
615        timeout_ms: u64,
616    ) -> Result<bool, BrowserError> {
617        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
618
619        // Snapshot the URL at entry for UrlChanged comparisons.
620        // Errors here turn into an empty baseline rather than
621        // failing the wait — a missing baseline matches anything.
622        let entry_url = self.get_current_url().unwrap_or_default();
623
624        loop {
625            let met = match condition {
626                WaitCondition::PageLoaded => self.is_page_loaded().await?,
627                WaitCondition::UrlChanged => {
628                    let now = self.get_current_url().unwrap_or_default();
629                    !now.is_empty() && now != entry_url
630                }
631                WaitCondition::A11yContainsText { text } => {
632                    let needle = text.to_lowercase();
633                    let nodes = self.get_accessibility_tree().await?;
634                    nodes.iter().any(|n| {
635                        n.name
636                            .as_ref()
637                            .map(|name| name.to_lowercase().contains(&needle))
638                            .unwrap_or(false)
639                    })
640                }
641                WaitCondition::ElementWithName {
642                    name_contains,
643                    role,
644                } => {
645                    self.element_exists_a11y(name_contains, role.as_deref())
646                        .await?
647                }
648            };
649            if met {
650                return Ok(true);
651            }
652            if tokio::time::Instant::now() >= deadline {
653                return Ok(false);
654            }
655            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
656        }
657    }
658
659    async fn element_exists_a11y(
660        &self,
661        name_contains: &str,
662        role: Option<&str>,
663    ) -> Result<bool, BrowserError> {
664        let nodes = self.get_accessibility_tree().await?;
665        Ok(nodes.iter().any(|n| {
666            let name_match = n
667                .name
668                .as_ref()
669                .map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
670                .unwrap_or(false);
671            if !name_match {
672                return false;
673            }
674            match role {
675                Some(r) => n.role.to_lowercase() == r.to_lowercase(),
676                None => true,
677            }
678        }))
679    }
680
681    async fn set_cookies(
682        &self,
683        cookies: &[crate::models::CookieParam],
684    ) -> Result<(), BrowserError> {
685        let page = self.get_page().await?;
686        for cookie in cookies {
687            let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
688                &cookie.name,
689                &cookie.value,
690            );
691            cdp_cookie.domain = Some(cookie.domain.clone());
692            cdp_cookie.path = Some(cookie.path.clone());
693            if cookie.secure {
694                cdp_cookie.secure = Some(true);
695            }
696            if cookie.http_only {
697                cdp_cookie.http_only = Some(true);
698            }
699            page.set_cookie(cdp_cookie)
700                .await
701                .map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
702        }
703        Ok(())
704    }
705
706    async fn set_local_storage(
707        &self,
708        origin: &str,
709        items: &[(String, String)],
710    ) -> Result<(), BrowserError> {
711        let page = self.get_page().await?;
712        // localStorage is origin-scoped — require the page already be at
713        // the target origin so we don't silently navigate behind the
714        // caller's back. Callers replay via `set_local_storage` after a
715        // `navigate` to the matching origin (or before first `navigate`
716        // if the caller wants the script to navigate elsewhere).
717        let current = self.get_current_url().unwrap_or_default();
718        if !current_origin_matches(&current, origin) {
719            return Err(BrowserError::PlatformInternal(format!(
720                "set_local_storage: page must be at origin '{}' first (currently '{}'). \
721                 Add a `navigate` op before set_local_storage, or call set_local_storage \
722                 before any navigate (pre-page state).",
723                origin, current
724            )));
725        }
726
727        // Don't swallow JSON encoding errors — `serde_json::to_string` on a
728        // &str can fail in theory; treat that as a platform bug, not a
729        // silent empty string.
730        for (key, value) in items {
731            let k = serde_json::to_string(key)
732                .map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
733            let v = serde_json::to_string(value)
734                .map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
735            let js = format!("localStorage.setItem({}, {})", k, v);
736            page.evaluate(js).await.map_err(|e| {
737                BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
738            })?;
739        }
740        Ok(())
741    }
742
743    async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
744        let page = self.get_page().await?;
745        // Enable network domain first
746        page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
747            .await
748            .map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;
749
750        let header_obj: serde_json::Value = headers
751            .iter()
752            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
753            .collect::<serde_json::Map<String, serde_json::Value>>()
754            .into();
755        let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
756            chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
757        );
758        page.execute(params).await.map_err(|e| {
759            BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
760        })?;
761        Ok(())
762    }
763
764    async fn shutdown(&self) -> Result<(), BrowserError> {
765        // Order matters:
766        //   1. Close the active page (frees CDP resources).
767        //   2. Abort the CDP event handler task so the channel
768        //      isn't held open while we try to close the Browser.
769        //   3. Send Browser.close via CDP, then wait on the Child.
770        //
771        // Each step is bounded by a short timeout so a hung or
772        // crashed Chrome can't wedge shutdown. If `close`/`wait`
773        // time out the explicit kill in `Drop` (or the next call
774        // path) still terminates the process by PID.
775        if let Some(page) = self.page.write().await.take() {
776            let _ = timeout(Duration::from_secs(2), page.close()).await;
777        }
778        if let Some(h) = self
779            .handler_task
780            .lock()
781            .ok()
782            .and_then(|mut g| g.take())
783        {
784            h.abort();
785        }
786        if let Some(mut browser) = self._browser.write().await.take() {
787            // Best-effort graceful close, then reap. If `close` is
788            // unresponsive (Chrome already dead, CDP channel torn
789            // down, etc.) fall back to `kill` so we never leave a
790            // running subprocess behind.
791            let close_ok = timeout(Duration::from_secs(2), browser.close())
792                .await
793                .map(|r| r.is_ok())
794                .unwrap_or(false);
795            if !close_ok {
796                let _ = timeout(Duration::from_secs(2), browser.kill()).await;
797            }
798            let _ = timeout(Duration::from_secs(2), browser.wait()).await;
799        }
800        Ok(())
801    }
802}
803
804impl Drop for ChromiumBackend {
805    /// Synchronous backstop for the `shutdown()` happy path.
806    ///
807    /// `shutdown()` is async, so callers who let a `ChromiumBackend`
808    /// drop on a panic, an early return, or process exit never get a
809    /// chance to run it. chromiumoxide's own `Browser::Drop` relies
810    /// on tokio's `kill_on_drop`, which only fires while the tokio
811    /// runtime is alive — which it usually isn't during teardown.
812    ///
813    /// macOS does not deliver a parent-death signal, so any Chrome
814    /// subprocess still alive at this moment would be reparented to
815    /// launchd (PPID=1) and leak forever. We avoid that by SIGKILL'ing
816    /// the captured PID directly.
817    fn drop(&mut self) {
818        // Abort the CDP event-pump task. `abort()` is non-blocking;
819        // the task is detached after this and won't be observable.
820        if let Some(h) = self
821            .handler_task
822            .get_mut()
823            .ok()
824            .and_then(|g| g.take())
825        {
826            h.abort();
827        }
828        // SIGKILL the Chrome subprocess if we still have its PID.
829        // `kill(pid, 0)` is a liveness probe — if it errors with
830        // ESRCH the process is already gone and we skip the signal.
831        #[cfg(unix)]
832        if let Some(pid) = self.chrome_pid {
833            // SAFETY: `kill(2)` is a syscall with no aliasing or
834            // memory-safety concerns. We only read errno via the
835            // return value.
836            unsafe {
837                if libc::kill(pid as libc::pid_t, 0) == 0 {
838                    libc::kill(pid as libc::pid_t, libc::SIGKILL);
839                }
840            }
841        }
842        // On non-Unix targets we rely on tokio's `kill_on_drop`,
843        // which on Windows uses TerminateProcess synchronously
844        // from the Child's Drop. The orphan pattern that motivated
845        // this fix is macOS-specific.
846    }
847}