Skip to main content

browser_commander/browser/
chromiumoxide_adapter.rs

1//! Chromiumoxide-backed [`EngineAdapter`] implementation.
2//!
3//! Wraps a live [`chromiumoxide::Page`] and the owning [`chromiumoxide::Browser`]
4//! so that browser-commander operations (goto, click, fill, evaluate, ...) can
5//! be executed against a real Chromium instance launched via the Chrome
6//! DevTools Protocol.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams;
14use chromiumoxide::{Browser as CdpBrowser, Page as CdpPage};
15use tokio::sync::Mutex;
16use tokio::task::JoinHandle;
17
18use crate::browser::media::ColorScheme;
19use crate::core::engine::{ElementInfo, EngineAdapter, EngineError, EngineType, PdfOptions};
20
21/// A [`EngineAdapter`] that drives a Chromium browser through
22/// `chromiumoxide`.
23///
24/// Obtained from [`launch_browser`](super::launcher::launch_browser).
25/// The adapter owns the browser handle along with the background task that
26/// services CDP events; dropping the adapter (or calling
27/// [`ChromiumoxidePage::close`]) terminates the browser process.
28pub struct ChromiumoxidePage {
29    page: CdpPage,
30    browser: Arc<Mutex<Option<CdpBrowser>>>,
31    handler_task: Arc<Mutex<Option<JoinHandle<()>>>>,
32    user_data_dir: PathBuf,
33}
34
35impl ChromiumoxidePage {
36    pub(crate) fn new(
37        page: CdpPage,
38        browser: CdpBrowser,
39        handler_task: JoinHandle<()>,
40        user_data_dir: PathBuf,
41    ) -> Self {
42        Self {
43            page,
44            browser: Arc::new(Mutex::new(Some(browser))),
45            handler_task: Arc::new(Mutex::new(Some(handler_task))),
46            user_data_dir,
47        }
48    }
49
50    /// Access the underlying [`chromiumoxide::Page`] for engine-specific
51    /// operations that are not yet covered by the unified API.
52    pub fn raw_page(&self) -> &CdpPage {
53        &self.page
54    }
55
56    /// The resolved user data directory used for this browser session.
57    pub fn user_data_dir(&self) -> &PathBuf {
58        &self.user_data_dir
59    }
60
61    /// Emulate a CSS `prefers-color-scheme` media feature on the live page.
62    pub async fn set_color_scheme(&self, scheme: Option<&ColorScheme>) -> Result<(), EngineError> {
63        use chromiumoxide::cdp::browser_protocol::emulation::{
64            MediaFeature, SetEmulatedMediaParams,
65        };
66
67        let features = match scheme {
68            Some(cs) => vec![MediaFeature {
69                name: "prefers-color-scheme".to_string(),
70                value: cs.as_str().to_string(),
71            }],
72            None => Vec::new(),
73        };
74
75        self.page
76            .execute(SetEmulatedMediaParams::builder().features(features).build())
77            .await
78            .map_err(to_engine_error)?;
79        Ok(())
80    }
81
82    /// Close the browser and terminate the background CDP handler task.
83    ///
84    /// Idempotent: calling more than once is a no-op.
85    pub async fn close(&self) -> Result<(), EngineError> {
86        let browser = self.browser.lock().await.take();
87        if let Some(mut browser) = browser {
88            let _ = browser.close().await;
89            let _ = browser.wait().await;
90        }
91        let handle = self.handler_task.lock().await.take();
92        if let Some(handle) = handle {
93            handle.abort();
94            let _ = handle.await;
95        }
96        Ok(())
97    }
98}
99
100impl std::fmt::Debug for ChromiumoxidePage {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("ChromiumoxidePage")
103            .field("user_data_dir", &self.user_data_dir)
104            .finish()
105    }
106}
107
108impl Drop for ChromiumoxidePage {
109    fn drop(&mut self) {
110        // Best-effort cleanup without blocking the current runtime. If the
111        // user did not call `close()` explicitly, abort the background task
112        // so the Tokio runtime can terminate cleanly.
113        if let Ok(mut guard) = self.handler_task.try_lock() {
114            if let Some(handle) = guard.take() {
115                handle.abort();
116            }
117        }
118    }
119}
120
121fn to_engine_error(err: impl std::fmt::Display) -> EngineError {
122    EngineError::Browser(err.to_string())
123}
124
125async fn eval_value(page: &CdpPage, script: String) -> Result<serde_json::Value, EngineError> {
126    let result = page.evaluate(script).await.map_err(to_engine_error)?;
127    Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
128}
129
130fn js_selector_call(selector: &str, body: &str) -> String {
131    // Build a JS snippet that invokes `body` on the element matched by
132    // `selector`, returning `null` when nothing matches. `body` must be a JS
133    // expression or statement sequence referring to `el` as the element.
134    format!(
135        r#"(() => {{
136            const el = document.querySelector({});
137            if (!el) return null;
138            {}
139        }})()"#,
140        serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string()),
141        body
142    )
143}
144
145#[async_trait]
146impl EngineAdapter for ChromiumoxidePage {
147    fn engine_type(&self) -> EngineType {
148        EngineType::Chromiumoxide
149    }
150
151    async fn url(&self) -> Result<String, EngineError> {
152        let url = self.page.url().await.map_err(to_engine_error)?;
153        Ok(url.unwrap_or_default())
154    }
155
156    async fn goto(&self, url: &str) -> Result<(), EngineError> {
157        self.page.goto(url).await.map_err(to_engine_error)?;
158        self.page
159            .wait_for_navigation()
160            .await
161            .map_err(to_engine_error)?;
162        Ok(())
163    }
164
165    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError> {
166        match self.page.find_element(selector).await {
167            Ok(element) => {
168                let tag_name = element
169                    .attribute("tagName")
170                    .await
171                    .map_err(to_engine_error)?
172                    .unwrap_or_else(|| "UNKNOWN".to_string());
173                let text = element.inner_text().await.map_err(to_engine_error)?;
174                let bounding_box = element
175                    .bounding_box()
176                    .await
177                    .ok()
178                    .map(|b| (b.x, b.y, b.width, b.height));
179                Ok(Some(ElementInfo {
180                    tag_name,
181                    text_content: text,
182                    is_visible: bounding_box.is_some(),
183                    is_enabled: true,
184                    bounding_box,
185                }))
186            }
187            Err(_) => Ok(None),
188        }
189    }
190
191    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError> {
192        let elements = self
193            .page
194            .find_elements(selector)
195            .await
196            .map_err(to_engine_error)?;
197        let mut infos = Vec::with_capacity(elements.len());
198        for element in elements {
199            let tag_name = element
200                .attribute("tagName")
201                .await
202                .map_err(to_engine_error)?
203                .unwrap_or_else(|| "UNKNOWN".to_string());
204            let text = element.inner_text().await.map_err(to_engine_error)?;
205            let bounding_box = element
206                .bounding_box()
207                .await
208                .ok()
209                .map(|b| (b.x, b.y, b.width, b.height));
210            infos.push(ElementInfo {
211                tag_name,
212                text_content: text,
213                is_visible: bounding_box.is_some(),
214                is_enabled: true,
215                bounding_box,
216            });
217        }
218        Ok(infos)
219    }
220
221    async fn count(&self, selector: &str) -> Result<usize, EngineError> {
222        let script = format!(
223            "document.querySelectorAll({}).length",
224            serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string())
225        );
226        let value = eval_value(&self.page, script).await?;
227        Ok(value.as_u64().unwrap_or(0) as usize)
228    }
229
230    async fn click(&self, selector: &str) -> Result<(), EngineError> {
231        let element = self
232            .page
233            .find_element(selector)
234            .await
235            .map_err(to_engine_error)?;
236        element.click().await.map_err(to_engine_error)?;
237        Ok(())
238    }
239
240    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
241        // Clear the current value, then type the new text.
242        let clear_script = js_selector_call(
243            selector,
244            "el.focus(); if ('value' in el) { el.value = ''; \
245             el.dispatchEvent(new Event('input', {bubbles:true})); } return true;",
246        );
247        eval_value(&self.page, clear_script).await?;
248        let element = self
249            .page
250            .find_element(selector)
251            .await
252            .map_err(to_engine_error)?;
253        element.click().await.map_err(to_engine_error)?;
254        element.type_str(text).await.map_err(to_engine_error)?;
255        Ok(())
256    }
257
258    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError> {
259        let element = self
260            .page
261            .find_element(selector)
262            .await
263            .map_err(to_engine_error)?;
264        element.click().await.map_err(to_engine_error)?;
265        element.type_str(text).await.map_err(to_engine_error)?;
266        Ok(())
267    }
268
269    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError> {
270        match self.page.find_element(selector).await {
271            Ok(element) => element.inner_text().await.map_err(to_engine_error),
272            Err(_) => Ok(None),
273        }
274    }
275
276    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError> {
277        let script = js_selector_call(selector, "return 'value' in el ? el.value : null;");
278        let value = eval_value(&self.page, script).await?;
279        Ok(match value {
280            serde_json::Value::Null => None,
281            serde_json::Value::String(s) => Some(s),
282            other => Some(other.to_string()),
283        })
284    }
285
286    async fn get_attribute(
287        &self,
288        selector: &str,
289        attribute: &str,
290    ) -> Result<Option<String>, EngineError> {
291        let script = js_selector_call(
292            selector,
293            &format!(
294                "return el.getAttribute({});",
295                serde_json::to_string(attribute).unwrap_or_else(|_| "\"\"".to_string())
296            ),
297        );
298        let value = eval_value(&self.page, script).await?;
299        Ok(match value {
300            serde_json::Value::Null => None,
301            serde_json::Value::String(s) => Some(s),
302            other => Some(other.to_string()),
303        })
304    }
305
306    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError> {
307        let script = js_selector_call(
308            selector,
309            "const style = window.getComputedStyle(el); \
310             if (style.display === 'none' || style.visibility === 'hidden') return false; \
311             const rect = el.getBoundingClientRect(); \
312             return rect.width > 0 && rect.height > 0;",
313        );
314        let value = eval_value(&self.page, script).await?;
315        Ok(value.as_bool().unwrap_or(false))
316    }
317
318    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError> {
319        let script = js_selector_call(selector, "return !el.disabled;");
320        let value = eval_value(&self.page, script).await?;
321        Ok(value.as_bool().unwrap_or(false))
322    }
323
324    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError> {
325        let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
326        loop {
327            if self.page.find_element(selector).await.is_ok() {
328                return Ok(());
329            }
330            if std::time::Instant::now() >= deadline {
331                return Err(EngineError::Timeout(format!(
332                    "wait_for_selector: {} not found in {}ms",
333                    selector, timeout_ms
334                )));
335            }
336            tokio::time::sleep(Duration::from_millis(100)).await;
337        }
338    }
339
340    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError> {
341        let element = self
342            .page
343            .find_element(selector)
344            .await
345            .map_err(to_engine_error)?;
346        element.scroll_into_view().await.map_err(to_engine_error)?;
347        Ok(())
348    }
349
350    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError> {
351        eval_value(&self.page, script.to_string()).await
352    }
353
354    async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
355        use chromiumoxide::page::ScreenshotParams;
356        self.page
357            .screenshot(ScreenshotParams::builder().build())
358            .await
359            .map_err(to_engine_error)
360    }
361
362    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
363        let mut builder = PrintToPdfParams::builder().print_background(options.print_background);
364        if let Some(scale) = options.scale {
365            builder = builder.scale(scale);
366        }
367        // Margins are interpreted as inches. browser-commander accepts CSS
368        // margin strings; translate common units so callers can pass
369        // e.g. "1cm" / "0.5in" / "10mm".
370        if let Some(v) = css_length_to_inches(options.margin_top.as_deref()) {
371            builder = builder.margin_top(v);
372        }
373        if let Some(v) = css_length_to_inches(options.margin_bottom.as_deref()) {
374            builder = builder.margin_bottom(v);
375        }
376        if let Some(v) = css_length_to_inches(options.margin_left.as_deref()) {
377            builder = builder.margin_left(v);
378        }
379        if let Some(v) = css_length_to_inches(options.margin_right.as_deref()) {
380            builder = builder.margin_right(v);
381        }
382        if let Some(format) = options.format.as_deref() {
383            if let Some((w, h)) = paper_format_inches(format) {
384                builder = builder.paper_width(w).paper_height(h);
385            }
386        }
387        let params = builder.build();
388        let bytes = self.page.pdf(params).await.map_err(to_engine_error)?;
389        if let Some(path) = options.path {
390            tokio::fs::write(path, &bytes)
391                .await
392                .map_err(|e| EngineError::Browser(format!("failed to write pdf: {}", e)))?;
393        }
394        Ok(bytes)
395    }
396
397    async fn bring_to_front(&self) -> Result<(), EngineError> {
398        self.page.bring_to_front().await.map_err(to_engine_error)?;
399        Ok(())
400    }
401
402    async fn wait_for_navigation(&self, _timeout_ms: u64) -> Result<(), EngineError> {
403        self.page
404            .wait_for_navigation()
405            .await
406            .map_err(to_engine_error)?;
407        Ok(())
408    }
409
410    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
411        use chromiumoxide::cdp::browser_protocol::input::{
412            DispatchKeyEventParams, DispatchKeyEventType,
413        };
414        self.page
415            .execute(
416                DispatchKeyEventParams::builder()
417                    .r#type(DispatchKeyEventType::KeyDown)
418                    .key(key.to_string())
419                    .build()
420                    .map_err(EngineError::Browser)?,
421            )
422            .await
423            .map_err(to_engine_error)?;
424        self.page
425            .execute(
426                DispatchKeyEventParams::builder()
427                    .r#type(DispatchKeyEventType::KeyUp)
428                    .key(key.to_string())
429                    .build()
430                    .map_err(EngineError::Browser)?,
431            )
432            .await
433            .map_err(to_engine_error)?;
434        Ok(())
435    }
436
437    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
438        use chromiumoxide::cdp::browser_protocol::input::{
439            DispatchKeyEventParams, DispatchKeyEventType,
440        };
441        for ch in text.chars() {
442            self.page
443                .execute(
444                    DispatchKeyEventParams::builder()
445                        .r#type(DispatchKeyEventType::Char)
446                        .text(ch.to_string())
447                        .build()
448                        .map_err(EngineError::Browser)?,
449                )
450                .await
451                .map_err(to_engine_error)?;
452        }
453        Ok(())
454    }
455
456    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
457        use chromiumoxide::cdp::browser_protocol::input::{
458            DispatchKeyEventParams, DispatchKeyEventType,
459        };
460        self.page
461            .execute(
462                DispatchKeyEventParams::builder()
463                    .r#type(DispatchKeyEventType::KeyDown)
464                    .key(key.to_string())
465                    .build()
466                    .map_err(EngineError::Browser)?,
467            )
468            .await
469            .map_err(to_engine_error)?;
470        Ok(())
471    }
472
473    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
474        use chromiumoxide::cdp::browser_protocol::input::{
475            DispatchKeyEventParams, DispatchKeyEventType,
476        };
477        self.page
478            .execute(
479                DispatchKeyEventParams::builder()
480                    .r#type(DispatchKeyEventType::KeyUp)
481                    .key(key.to_string())
482                    .build()
483                    .map_err(EngineError::Browser)?,
484            )
485            .await
486            .map_err(to_engine_error)?;
487        Ok(())
488    }
489}
490
491/// Convert a CSS length (`"1in"`, `"2cm"`, `"10mm"`, `"72px"`) to inches for
492/// the CDP PDF API. Returns `None` on unknown/missing units.
493fn css_length_to_inches(value: Option<&str>) -> Option<f64> {
494    let raw = value?.trim();
495    if raw.is_empty() {
496        return None;
497    }
498    let (num_str, unit) = raw
499        .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
500        .map(|idx| raw.split_at(idx))
501        .unwrap_or((raw, "in"));
502    let value: f64 = num_str.parse().ok()?;
503    Some(match unit.trim() {
504        "in" | "" => value,
505        "cm" => value / 2.54,
506        "mm" => value / 25.4,
507        "px" => value / 96.0,
508        "pt" => value / 72.0,
509        _ => return None,
510    })
511}
512
513/// Convert common paper format names into `(width, height)` in inches.
514fn paper_format_inches(name: &str) -> Option<(f64, f64)> {
515    let n = name.trim().to_ascii_lowercase();
516    Some(match n.as_str() {
517        "letter" => (8.5, 11.0),
518        "legal" => (8.5, 14.0),
519        "tabloid" => (11.0, 17.0),
520        "ledger" => (17.0, 11.0),
521        "a0" => (33.1, 46.8),
522        "a1" => (23.4, 33.1),
523        "a2" => (16.54, 23.4),
524        "a3" => (11.7, 16.54),
525        "a4" => (8.27, 11.69),
526        "a5" => (5.83, 8.27),
527        "a6" => (4.13, 5.83),
528        _ => return None,
529    })
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn css_length_to_inches_handles_common_units() {
538        assert_eq!(css_length_to_inches(Some("1in")), Some(1.0));
539        assert_eq!(css_length_to_inches(Some("2.54cm")), Some(1.0));
540        assert_eq!(css_length_to_inches(Some("25.4mm")), Some(1.0));
541        assert_eq!(css_length_to_inches(Some("96px")), Some(1.0));
542        assert_eq!(css_length_to_inches(Some("72pt")), Some(1.0));
543    }
544
545    #[test]
546    fn css_length_to_inches_rejects_unknown_units() {
547        assert_eq!(css_length_to_inches(Some("10xx")), None);
548        assert_eq!(css_length_to_inches(None), None);
549        assert_eq!(css_length_to_inches(Some("")), None);
550    }
551
552    #[test]
553    fn css_length_to_inches_defaults_to_inches() {
554        // A bare number is treated as inches.
555        assert_eq!(css_length_to_inches(Some("1")), Some(1.0));
556    }
557
558    #[test]
559    fn paper_format_inches_known_formats() {
560        assert_eq!(paper_format_inches("A4"), Some((8.27, 11.69)));
561        assert_eq!(paper_format_inches("letter"), Some((8.5, 11.0)));
562        assert_eq!(paper_format_inches("Legal"), Some((8.5, 14.0)));
563    }
564
565    #[test]
566    fn paper_format_inches_unknown() {
567        assert!(paper_format_inches("weird").is_none());
568    }
569}