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 mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
241        // CDP input is delivered at raw viewport coordinates, so nothing is
242        // scrolled into view - which is exactly what `ClickScroll::None` asks
243        // for. The caller has already hit-tested the point.
244        self.page
245            .click(chromiumoxide::layout::Point::new(x, y))
246            .await
247            .map_err(to_engine_error)?;
248        Ok(())
249    }
250
251    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
252        // Clear the current value, then type the new text.
253        let clear_script = js_selector_call(
254            selector,
255            "el.focus(); if ('value' in el) { el.value = ''; \
256             el.dispatchEvent(new Event('input', {bubbles:true})); } return true;",
257        );
258        eval_value(&self.page, clear_script).await?;
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 type_text(&self, selector: &str, text: &str) -> Result<(), EngineError> {
270        let element = self
271            .page
272            .find_element(selector)
273            .await
274            .map_err(to_engine_error)?;
275        element.click().await.map_err(to_engine_error)?;
276        element.type_str(text).await.map_err(to_engine_error)?;
277        Ok(())
278    }
279
280    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError> {
281        match self.page.find_element(selector).await {
282            Ok(element) => element.inner_text().await.map_err(to_engine_error),
283            Err(_) => Ok(None),
284        }
285    }
286
287    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError> {
288        let script = js_selector_call(selector, "return 'value' in el ? el.value : null;");
289        let value = eval_value(&self.page, script).await?;
290        Ok(match value {
291            serde_json::Value::Null => None,
292            serde_json::Value::String(s) => Some(s),
293            other => Some(other.to_string()),
294        })
295    }
296
297    async fn get_attribute(
298        &self,
299        selector: &str,
300        attribute: &str,
301    ) -> Result<Option<String>, EngineError> {
302        let script = js_selector_call(
303            selector,
304            &format!(
305                "return el.getAttribute({});",
306                serde_json::to_string(attribute).unwrap_or_else(|_| "\"\"".to_string())
307            ),
308        );
309        let value = eval_value(&self.page, script).await?;
310        Ok(match value {
311            serde_json::Value::Null => None,
312            serde_json::Value::String(s) => Some(s),
313            other => Some(other.to_string()),
314        })
315    }
316
317    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError> {
318        let script = js_selector_call(
319            selector,
320            "const style = window.getComputedStyle(el); \
321             if (style.display === 'none' || style.visibility === 'hidden') return false; \
322             const rect = el.getBoundingClientRect(); \
323             return rect.width > 0 && rect.height > 0;",
324        );
325        let value = eval_value(&self.page, script).await?;
326        Ok(value.as_bool().unwrap_or(false))
327    }
328
329    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError> {
330        let script = js_selector_call(selector, "return !el.disabled;");
331        let value = eval_value(&self.page, script).await?;
332        Ok(value.as_bool().unwrap_or(false))
333    }
334
335    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError> {
336        let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
337        loop {
338            if self.page.find_element(selector).await.is_ok() {
339                return Ok(());
340            }
341            if std::time::Instant::now() >= deadline {
342                return Err(EngineError::Timeout(format!(
343                    "wait_for_selector: {} not found in {}ms",
344                    selector, timeout_ms
345                )));
346            }
347            tokio::time::sleep(Duration::from_millis(100)).await;
348        }
349    }
350
351    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError> {
352        let element = self
353            .page
354            .find_element(selector)
355            .await
356            .map_err(to_engine_error)?;
357        element.scroll_into_view().await.map_err(to_engine_error)?;
358        Ok(())
359    }
360
361    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError> {
362        eval_value(&self.page, script.to_string()).await
363    }
364
365    async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
366        use chromiumoxide::page::ScreenshotParams;
367        self.page
368            .screenshot(ScreenshotParams::builder().build())
369            .await
370            .map_err(to_engine_error)
371    }
372
373    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
374        let mut builder = PrintToPdfParams::builder().print_background(options.print_background);
375        if let Some(scale) = options.scale {
376            builder = builder.scale(scale);
377        }
378        // Margins are interpreted as inches. browser-commander accepts CSS
379        // margin strings; translate common units so callers can pass
380        // e.g. "1cm" / "0.5in" / "10mm".
381        if let Some(v) = css_length_to_inches(options.margin_top.as_deref()) {
382            builder = builder.margin_top(v);
383        }
384        if let Some(v) = css_length_to_inches(options.margin_bottom.as_deref()) {
385            builder = builder.margin_bottom(v);
386        }
387        if let Some(v) = css_length_to_inches(options.margin_left.as_deref()) {
388            builder = builder.margin_left(v);
389        }
390        if let Some(v) = css_length_to_inches(options.margin_right.as_deref()) {
391            builder = builder.margin_right(v);
392        }
393        if let Some(format) = options.format.as_deref() {
394            if let Some((w, h)) = paper_format_inches(format) {
395                builder = builder.paper_width(w).paper_height(h);
396            }
397        }
398        let params = builder.build();
399        let bytes = self.page.pdf(params).await.map_err(to_engine_error)?;
400        if let Some(path) = options.path {
401            tokio::fs::write(path, &bytes)
402                .await
403                .map_err(|e| EngineError::Browser(format!("failed to write pdf: {}", e)))?;
404        }
405        Ok(bytes)
406    }
407
408    async fn bring_to_front(&self) -> Result<(), EngineError> {
409        self.page.bring_to_front().await.map_err(to_engine_error)?;
410        Ok(())
411    }
412
413    async fn wait_for_navigation(&self, _timeout_ms: u64) -> Result<(), EngineError> {
414        self.page
415            .wait_for_navigation()
416            .await
417            .map_err(to_engine_error)?;
418        Ok(())
419    }
420
421    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
422        use chromiumoxide::cdp::browser_protocol::input::{
423            DispatchKeyEventParams, DispatchKeyEventType,
424        };
425        self.page
426            .execute(
427                DispatchKeyEventParams::builder()
428                    .r#type(DispatchKeyEventType::KeyDown)
429                    .key(key.to_string())
430                    .build()
431                    .map_err(EngineError::Browser)?,
432            )
433            .await
434            .map_err(to_engine_error)?;
435        self.page
436            .execute(
437                DispatchKeyEventParams::builder()
438                    .r#type(DispatchKeyEventType::KeyUp)
439                    .key(key.to_string())
440                    .build()
441                    .map_err(EngineError::Browser)?,
442            )
443            .await
444            .map_err(to_engine_error)?;
445        Ok(())
446    }
447
448    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
449        use chromiumoxide::cdp::browser_protocol::input::{
450            DispatchKeyEventParams, DispatchKeyEventType,
451        };
452        for ch in text.chars() {
453            self.page
454                .execute(
455                    DispatchKeyEventParams::builder()
456                        .r#type(DispatchKeyEventType::Char)
457                        .text(ch.to_string())
458                        .build()
459                        .map_err(EngineError::Browser)?,
460                )
461                .await
462                .map_err(to_engine_error)?;
463        }
464        Ok(())
465    }
466
467    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
468        use chromiumoxide::cdp::browser_protocol::input::{
469            DispatchKeyEventParams, DispatchKeyEventType,
470        };
471        self.page
472            .execute(
473                DispatchKeyEventParams::builder()
474                    .r#type(DispatchKeyEventType::KeyDown)
475                    .key(key.to_string())
476                    .build()
477                    .map_err(EngineError::Browser)?,
478            )
479            .await
480            .map_err(to_engine_error)?;
481        Ok(())
482    }
483
484    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
485        use chromiumoxide::cdp::browser_protocol::input::{
486            DispatchKeyEventParams, DispatchKeyEventType,
487        };
488        self.page
489            .execute(
490                DispatchKeyEventParams::builder()
491                    .r#type(DispatchKeyEventType::KeyUp)
492                    .key(key.to_string())
493                    .build()
494                    .map_err(EngineError::Browser)?,
495            )
496            .await
497            .map_err(to_engine_error)?;
498        Ok(())
499    }
500}
501
502/// Convert a CSS length (`"1in"`, `"2cm"`, `"10mm"`, `"72px"`) to inches for
503/// the CDP PDF API. Returns `None` on unknown/missing units.
504fn css_length_to_inches(value: Option<&str>) -> Option<f64> {
505    let raw = value?.trim();
506    if raw.is_empty() {
507        return None;
508    }
509    let (num_str, unit) = raw
510        .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
511        .map(|idx| raw.split_at(idx))
512        .unwrap_or((raw, "in"));
513    let value: f64 = num_str.parse().ok()?;
514    Some(match unit.trim() {
515        "in" | "" => value,
516        "cm" => value / 2.54,
517        "mm" => value / 25.4,
518        "px" => value / 96.0,
519        "pt" => value / 72.0,
520        _ => return None,
521    })
522}
523
524/// Convert common paper format names into `(width, height)` in inches.
525fn paper_format_inches(name: &str) -> Option<(f64, f64)> {
526    let n = name.trim().to_ascii_lowercase();
527    Some(match n.as_str() {
528        "letter" => (8.5, 11.0),
529        "legal" => (8.5, 14.0),
530        "tabloid" => (11.0, 17.0),
531        "ledger" => (17.0, 11.0),
532        "a0" => (33.1, 46.8),
533        "a1" => (23.4, 33.1),
534        "a2" => (16.54, 23.4),
535        "a3" => (11.7, 16.54),
536        "a4" => (8.27, 11.69),
537        "a5" => (5.83, 8.27),
538        "a6" => (4.13, 5.83),
539        _ => return None,
540    })
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn css_length_to_inches_handles_common_units() {
549        assert_eq!(css_length_to_inches(Some("1in")), Some(1.0));
550        assert_eq!(css_length_to_inches(Some("2.54cm")), Some(1.0));
551        assert_eq!(css_length_to_inches(Some("25.4mm")), Some(1.0));
552        assert_eq!(css_length_to_inches(Some("96px")), Some(1.0));
553        assert_eq!(css_length_to_inches(Some("72pt")), Some(1.0));
554    }
555
556    #[test]
557    fn css_length_to_inches_rejects_unknown_units() {
558        assert_eq!(css_length_to_inches(Some("10xx")), None);
559        assert_eq!(css_length_to_inches(None), None);
560        assert_eq!(css_length_to_inches(Some("")), None);
561    }
562
563    #[test]
564    fn css_length_to_inches_defaults_to_inches() {
565        // A bare number is treated as inches.
566        assert_eq!(css_length_to_inches(Some("1")), Some(1.0));
567    }
568
569    #[test]
570    fn paper_format_inches_known_formats() {
571        assert_eq!(paper_format_inches("A4"), Some((8.27, 11.69)));
572        assert_eq!(paper_format_inches("letter"), Some((8.5, 11.0)));
573        assert_eq!(paper_format_inches("Legal"), Some((8.5, 14.0)));
574    }
575
576    #[test]
577    fn paper_format_inches_unknown() {
578        assert!(paper_format_inches("weird").is_none());
579    }
580}