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