1use 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 futures::stream::BoxStream;
16use tokio::sync::Mutex;
17use tokio::task::JoinHandle;
18
19use crate::browser::chromiumoxide_trace;
20use crate::browser::media::ColorScheme;
21use crate::browser::storage_state::{restore_script, StorageState};
22use crate::core::engine::{
23 ElementInfo, EngineAdapter, EngineError, EngineType, PdfOptions, TraceEngineEvent,
24};
25
26pub struct ChromiumoxidePage {
34 page: CdpPage,
35 browser: Arc<Mutex<Option<CdpBrowser>>>,
36 handler_task: Arc<Mutex<Option<JoinHandle<()>>>>,
37 user_data_dir: PathBuf,
38}
39
40impl ChromiumoxidePage {
41 pub(crate) fn new(
42 page: CdpPage,
43 browser: CdpBrowser,
44 handler_task: JoinHandle<()>,
45 user_data_dir: PathBuf,
46 ) -> Self {
47 Self {
48 page,
49 browser: Arc::new(Mutex::new(Some(browser))),
50 handler_task: Arc::new(Mutex::new(Some(handler_task))),
51 user_data_dir,
52 }
53 }
54
55 pub fn raw_page(&self) -> &CdpPage {
58 &self.page
59 }
60
61 pub fn user_data_dir(&self) -> &PathBuf {
63 &self.user_data_dir
64 }
65
66 pub async fn set_color_scheme(&self, scheme: Option<&ColorScheme>) -> Result<(), EngineError> {
68 use chromiumoxide::cdp::browser_protocol::emulation::{
69 MediaFeature, SetEmulatedMediaParams,
70 };
71
72 let features = match scheme {
73 Some(cs) => vec![MediaFeature {
74 name: "prefers-color-scheme".to_string(),
75 value: cs.as_str().to_string(),
76 }],
77 None => Vec::new(),
78 };
79
80 self.page
81 .execute(SetEmulatedMediaParams::builder().features(features).build())
82 .await
83 .map_err(to_engine_error)?;
84 Ok(())
85 }
86
87 pub async fn close(&self) -> Result<(), EngineError> {
91 let browser = self.browser.lock().await.take();
92 if let Some(mut browser) = browser {
93 let _ = browser.close().await;
94 let _ = browser.wait().await;
95 }
96 let handle = self.handler_task.lock().await.take();
97 if let Some(handle) = handle {
98 handle.abort();
99 let _ = handle.await;
100 }
101 Ok(())
102 }
103}
104
105impl std::fmt::Debug for ChromiumoxidePage {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("ChromiumoxidePage")
108 .field("user_data_dir", &self.user_data_dir)
109 .finish()
110 }
111}
112
113impl Drop for ChromiumoxidePage {
114 fn drop(&mut self) {
115 if let Ok(mut guard) = self.handler_task.try_lock() {
119 if let Some(handle) = guard.take() {
120 handle.abort();
121 }
122 }
123 }
124}
125
126fn to_engine_error(err: impl std::fmt::Display) -> EngineError {
127 EngineError::Browser(err.to_string())
128}
129
130async fn eval_value(page: &CdpPage, script: String) -> Result<serde_json::Value, EngineError> {
131 let result = page.evaluate(script).await.map_err(to_engine_error)?;
132 Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
133}
134
135fn js_selector_call(selector: &str, body: &str) -> String {
136 format!(
140 r#"(() => {{
141 const el = document.querySelector({});
142 if (!el) return null;
143 {}
144 }})()"#,
145 serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string()),
146 body
147 )
148}
149
150#[async_trait]
151impl EngineAdapter for ChromiumoxidePage {
152 fn engine_type(&self) -> EngineType {
153 EngineType::Chromiumoxide
154 }
155
156 async fn url(&self) -> Result<String, EngineError> {
157 let url = self.page.url().await.map_err(to_engine_error)?;
158 Ok(url.unwrap_or_default())
159 }
160
161 async fn goto(&self, url: &str) -> Result<(), EngineError> {
162 self.page.goto(url).await.map_err(to_engine_error)?;
163 self.page
164 .wait_for_navigation()
165 .await
166 .map_err(to_engine_error)?;
167 Ok(())
168 }
169
170 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError> {
171 match self.page.find_element(selector).await {
172 Ok(element) => {
173 let tag_name = element
174 .attribute("tagName")
175 .await
176 .map_err(to_engine_error)?
177 .unwrap_or_else(|| "UNKNOWN".to_string());
178 let text = element.inner_text().await.map_err(to_engine_error)?;
179 let bounding_box = element
180 .bounding_box()
181 .await
182 .ok()
183 .map(|b| (b.x, b.y, b.width, b.height));
184 Ok(Some(ElementInfo {
185 tag_name,
186 text_content: text,
187 is_visible: bounding_box.is_some(),
188 is_enabled: true,
189 bounding_box,
190 }))
191 }
192 Err(_) => Ok(None),
193 }
194 }
195
196 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError> {
197 let elements = self
198 .page
199 .find_elements(selector)
200 .await
201 .map_err(to_engine_error)?;
202 let mut infos = Vec::with_capacity(elements.len());
203 for element in elements {
204 let tag_name = element
205 .attribute("tagName")
206 .await
207 .map_err(to_engine_error)?
208 .unwrap_or_else(|| "UNKNOWN".to_string());
209 let text = element.inner_text().await.map_err(to_engine_error)?;
210 let bounding_box = element
211 .bounding_box()
212 .await
213 .ok()
214 .map(|b| (b.x, b.y, b.width, b.height));
215 infos.push(ElementInfo {
216 tag_name,
217 text_content: text,
218 is_visible: bounding_box.is_some(),
219 is_enabled: true,
220 bounding_box,
221 });
222 }
223 Ok(infos)
224 }
225
226 async fn count(&self, selector: &str) -> Result<usize, EngineError> {
227 let script = format!(
228 "document.querySelectorAll({}).length",
229 serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string())
230 );
231 let value = eval_value(&self.page, script).await?;
232 Ok(value.as_u64().unwrap_or(0) as usize)
233 }
234
235 async fn click(&self, selector: &str) -> Result<(), EngineError> {
236 let element = self
237 .page
238 .find_element(selector)
239 .await
240 .map_err(to_engine_error)?;
241 element.click().await.map_err(to_engine_error)?;
242 Ok(())
243 }
244
245 async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
246 self.page
250 .click(chromiumoxide::layout::Point::new(x, y))
251 .await
252 .map_err(to_engine_error)?;
253 Ok(())
254 }
255
256 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
257 let clear_script = js_selector_call(
259 selector,
260 "el.focus(); if ('value' in el) { el.value = ''; \
261 el.dispatchEvent(new Event('input', {bubbles:true})); } return true;",
262 );
263 eval_value(&self.page, clear_script).await?;
264 let element = self
265 .page
266 .find_element(selector)
267 .await
268 .map_err(to_engine_error)?;
269 element.click().await.map_err(to_engine_error)?;
270 element.type_str(text).await.map_err(to_engine_error)?;
271 Ok(())
272 }
273
274 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError> {
275 let element = self
276 .page
277 .find_element(selector)
278 .await
279 .map_err(to_engine_error)?;
280 element.click().await.map_err(to_engine_error)?;
281 element.type_str(text).await.map_err(to_engine_error)?;
282 Ok(())
283 }
284
285 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError> {
286 match self.page.find_element(selector).await {
287 Ok(element) => element.inner_text().await.map_err(to_engine_error),
288 Err(_) => Ok(None),
289 }
290 }
291
292 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError> {
293 let script = js_selector_call(selector, "return 'value' in el ? el.value : null;");
294 let value = eval_value(&self.page, script).await?;
295 Ok(match value {
296 serde_json::Value::Null => None,
297 serde_json::Value::String(s) => Some(s),
298 other => Some(other.to_string()),
299 })
300 }
301
302 async fn get_attribute(
303 &self,
304 selector: &str,
305 attribute: &str,
306 ) -> Result<Option<String>, EngineError> {
307 let script = js_selector_call(
308 selector,
309 &format!(
310 "return el.getAttribute({});",
311 serde_json::to_string(attribute).unwrap_or_else(|_| "\"\"".to_string())
312 ),
313 );
314 let value = eval_value(&self.page, script).await?;
315 Ok(match value {
316 serde_json::Value::Null => None,
317 serde_json::Value::String(s) => Some(s),
318 other => Some(other.to_string()),
319 })
320 }
321
322 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError> {
323 let script = js_selector_call(
324 selector,
325 "const style = window.getComputedStyle(el); \
326 if (style.display === 'none' || style.visibility === 'hidden') return false; \
327 const rect = el.getBoundingClientRect(); \
328 return rect.width > 0 && rect.height > 0;",
329 );
330 let value = eval_value(&self.page, script).await?;
331 Ok(value.as_bool().unwrap_or(false))
332 }
333
334 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError> {
335 let script = js_selector_call(selector, "return !el.disabled;");
336 let value = eval_value(&self.page, script).await?;
337 Ok(value.as_bool().unwrap_or(false))
338 }
339
340 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError> {
341 let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
342 loop {
343 if self.page.find_element(selector).await.is_ok() {
344 return Ok(());
345 }
346 if std::time::Instant::now() >= deadline {
347 return Err(EngineError::Timeout(format!(
348 "wait_for_selector: {} not found in {}ms",
349 selector, timeout_ms
350 )));
351 }
352 tokio::time::sleep(Duration::from_millis(100)).await;
353 }
354 }
355
356 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError> {
357 let element = self
358 .page
359 .find_element(selector)
360 .await
361 .map_err(to_engine_error)?;
362 element.scroll_into_view().await.map_err(to_engine_error)?;
363 Ok(())
364 }
365
366 async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError> {
367 eval_value(&self.page, script.to_string()).await
368 }
369
370 async fn read_browser_version_page(&self) -> Result<serde_json::Value, EngineError> {
371 let page = {
372 let browser = self.browser.lock().await;
373 browser
374 .as_ref()
375 .ok_or_else(|| EngineError::Browser("browser is closed".into()))?
376 .new_page("chrome://version")
377 .await
378 .map_err(to_engine_error)?
379 };
380 let read = tokio::time::timeout(Duration::from_secs(10), async {
381 loop {
382 let value = eval_value(&page, crate::parity::VERSION_EXPRESSION.into()).await?;
383 if !value.is_null() {
384 return Ok::<_, EngineError>(value);
385 }
386 tokio::time::sleep(Duration::from_millis(100)).await;
387 }
388 })
389 .await
390 .map_err(|_| EngineError::Timeout("chrome://version did not render".into()));
391 let _ = page.close().await;
392 read?
393 }
394
395 async fn restore_storage_state(&self, value: serde_json::Value) -> Result<(), EngineError> {
396 use chromiumoxide::cdp::browser_protocol::network::CookieParam;
397
398 let state: StorageState = serde_json::from_value(value).map_err(to_engine_error)?;
399 let cookies = state
400 .cookies
401 .iter()
402 .map(|cookie| {
403 let mut value = cookie.clone();
404 if value.get("expires").and_then(serde_json::Value::as_f64) == Some(-1.0) {
405 value.as_object_mut().map(|cookie| cookie.remove("expires"));
406 }
407 serde_json::from_value::<CookieParam>(value).map_err(to_engine_error)
408 })
409 .collect::<Result<Vec<_>, _>>()?;
410 if !cookies.is_empty() {
411 let browser = self.browser.lock().await;
412 let browser = browser
413 .as_ref()
414 .ok_or_else(|| EngineError::Browser("browser is closed".to_string()))?;
415 browser
416 .set_cookies(cookies)
417 .await
418 .map_err(to_engine_error)?;
419 }
420 if !state.origins.is_empty() {
421 let script = restore_script(&state).map_err(to_engine_error)?;
422 self.page
423 .evaluate_on_new_document(script.clone())
424 .await
425 .map_err(to_engine_error)?;
426 eval_value(&self.page, script).await?;
427 }
428 Ok(())
429 }
430
431 async fn export_storage_state(&self) -> Result<serde_json::Value, EngineError> {
432 let cookies = {
433 let browser = self.browser.lock().await;
434 let browser = browser
435 .as_ref()
436 .ok_or_else(|| EngineError::Browser("browser is closed".to_string()))?;
437 browser.get_cookies().await.map_err(to_engine_error)?
438 };
439 let cookies = cookies
440 .into_iter()
441 .map(|cookie| {
442 let value = serde_json::to_value(cookie).map_err(to_engine_error)?;
443 let mut result = serde_json::Map::new();
444 for key in [
445 "name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite",
446 ] {
447 if let Some(value) = value.get(key) {
448 result.insert(key.to_string(), value.clone());
449 }
450 }
451 Ok(serde_json::Value::Object(result))
452 })
453 .collect::<Result<Vec<_>, EngineError>>()?;
454 let origin = eval_value(
455 &self.page,
456 "(() => { if (location.origin === 'null') return null; \
457 return { origin: location.origin, localStorage: \
458 Array.from({ length: localStorage.length }, (_, index) => { \
459 const name = localStorage.key(index); \
460 return { name, value: localStorage.getItem(name) }; }) }; })()"
461 .to_string(),
462 )
463 .await?;
464 let origins = if origin.is_null() {
465 vec![]
466 } else {
467 vec![origin]
468 };
469 Ok(serde_json::json!({ "cookies": cookies, "origins": origins }))
470 }
471
472 async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
473 use chromiumoxide::page::ScreenshotParams;
474 self.page
475 .screenshot(ScreenshotParams::builder().build())
476 .await
477 .map_err(to_engine_error)
478 }
479
480 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
481 let mut builder = PrintToPdfParams::builder().print_background(options.print_background);
482 if let Some(scale) = options.scale {
483 builder = builder.scale(scale);
484 }
485 if let Some(v) = css_length_to_inches(options.margin_top.as_deref()) {
489 builder = builder.margin_top(v);
490 }
491 if let Some(v) = css_length_to_inches(options.margin_bottom.as_deref()) {
492 builder = builder.margin_bottom(v);
493 }
494 if let Some(v) = css_length_to_inches(options.margin_left.as_deref()) {
495 builder = builder.margin_left(v);
496 }
497 if let Some(v) = css_length_to_inches(options.margin_right.as_deref()) {
498 builder = builder.margin_right(v);
499 }
500 if let Some(format) = options.format.as_deref() {
501 if let Some((w, h)) = paper_format_inches(format) {
502 builder = builder.paper_width(w).paper_height(h);
503 }
504 }
505 let params = builder.build();
506 let bytes = self.page.pdf(params).await.map_err(to_engine_error)?;
507 if let Some(path) = options.path {
508 tokio::fs::write(path, &bytes)
509 .await
510 .map_err(|e| EngineError::Browser(format!("failed to write pdf: {}", e)))?;
511 }
512 Ok(bytes)
513 }
514
515 async fn bring_to_front(&self) -> Result<(), EngineError> {
516 self.page.bring_to_front().await.map_err(to_engine_error)?;
517 Ok(())
518 }
519
520 async fn wait_for_navigation(&self, _timeout_ms: u64) -> Result<(), EngineError> {
521 self.page
522 .wait_for_navigation()
523 .await
524 .map_err(to_engine_error)?;
525 Ok(())
526 }
527
528 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
529 use chromiumoxide::cdp::browser_protocol::input::{
530 DispatchKeyEventParams, DispatchKeyEventType,
531 };
532 self.page
533 .execute(
534 DispatchKeyEventParams::builder()
535 .r#type(DispatchKeyEventType::KeyDown)
536 .key(key.to_string())
537 .build()
538 .map_err(EngineError::Browser)?,
539 )
540 .await
541 .map_err(to_engine_error)?;
542 self.page
543 .execute(
544 DispatchKeyEventParams::builder()
545 .r#type(DispatchKeyEventType::KeyUp)
546 .key(key.to_string())
547 .build()
548 .map_err(EngineError::Browser)?,
549 )
550 .await
551 .map_err(to_engine_error)?;
552 Ok(())
553 }
554
555 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
556 use chromiumoxide::cdp::browser_protocol::input::{
557 DispatchKeyEventParams, DispatchKeyEventType,
558 };
559 for ch in text.chars() {
560 self.page
561 .execute(
562 DispatchKeyEventParams::builder()
563 .r#type(DispatchKeyEventType::Char)
564 .text(ch.to_string())
565 .build()
566 .map_err(EngineError::Browser)?,
567 )
568 .await
569 .map_err(to_engine_error)?;
570 }
571 Ok(())
572 }
573
574 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
575 use chromiumoxide::cdp::browser_protocol::input::{
576 DispatchKeyEventParams, DispatchKeyEventType,
577 };
578 self.page
579 .execute(
580 DispatchKeyEventParams::builder()
581 .r#type(DispatchKeyEventType::KeyDown)
582 .key(key.to_string())
583 .build()
584 .map_err(EngineError::Browser)?,
585 )
586 .await
587 .map_err(to_engine_error)?;
588 Ok(())
589 }
590
591 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
592 use chromiumoxide::cdp::browser_protocol::input::{
593 DispatchKeyEventParams, DispatchKeyEventType,
594 };
595 self.page
596 .execute(
597 DispatchKeyEventParams::builder()
598 .r#type(DispatchKeyEventType::KeyUp)
599 .key(key.to_string())
600 .build()
601 .map_err(EngineError::Browser)?,
602 )
603 .await
604 .map_err(to_engine_error)?;
605 Ok(())
606 }
607
608 async fn add_init_script(&self, script: &str) -> Result<Option<String>, EngineError> {
609 chromiumoxide_trace::add_init_script(&self.page, script).await
610 }
611
612 async fn remove_init_script(&self, identifier: &str) -> Result<(), EngineError> {
613 chromiumoxide_trace::remove_init_script(&self.page, identifier).await
614 }
615
616 async fn trace_events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
617 chromiumoxide_trace::trace_events(&self.page).await
618 }
619}
620
621fn css_length_to_inches(value: Option<&str>) -> Option<f64> {
624 let raw = value?.trim();
625 if raw.is_empty() {
626 return None;
627 }
628 let (num_str, unit) = raw
629 .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
630 .map(|idx| raw.split_at(idx))
631 .unwrap_or((raw, "in"));
632 let value: f64 = num_str.parse().ok()?;
633 Some(match unit.trim() {
634 "in" | "" => value,
635 "cm" => value / 2.54,
636 "mm" => value / 25.4,
637 "px" => value / 96.0,
638 "pt" => value / 72.0,
639 _ => return None,
640 })
641}
642
643fn paper_format_inches(name: &str) -> Option<(f64, f64)> {
645 let n = name.trim().to_ascii_lowercase();
646 Some(match n.as_str() {
647 "letter" => (8.5, 11.0),
648 "legal" => (8.5, 14.0),
649 "tabloid" => (11.0, 17.0),
650 "ledger" => (17.0, 11.0),
651 "a0" => (33.1, 46.8),
652 "a1" => (23.4, 33.1),
653 "a2" => (16.54, 23.4),
654 "a3" => (11.7, 16.54),
655 "a4" => (8.27, 11.69),
656 "a5" => (5.83, 8.27),
657 "a6" => (4.13, 5.83),
658 _ => return None,
659 })
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn css_length_to_inches_handles_common_units() {
668 assert_eq!(css_length_to_inches(Some("1in")), Some(1.0));
669 assert_eq!(css_length_to_inches(Some("2.54cm")), Some(1.0));
670 assert_eq!(css_length_to_inches(Some("25.4mm")), Some(1.0));
671 assert_eq!(css_length_to_inches(Some("96px")), Some(1.0));
672 assert_eq!(css_length_to_inches(Some("72pt")), Some(1.0));
673 }
674
675 #[test]
676 fn css_length_to_inches_rejects_unknown_units() {
677 assert_eq!(css_length_to_inches(Some("10xx")), None);
678 assert_eq!(css_length_to_inches(None), None);
679 assert_eq!(css_length_to_inches(Some("")), None);
680 }
681
682 #[test]
683 fn css_length_to_inches_defaults_to_inches() {
684 assert_eq!(css_length_to_inches(Some("1")), Some(1.0));
686 }
687
688 #[test]
689 fn paper_format_inches_known_formats() {
690 assert_eq!(paper_format_inches("A4"), Some((8.27, 11.69)));
691 assert_eq!(paper_format_inches("letter"), Some((8.5, 11.0)));
692 assert_eq!(paper_format_inches("Legal"), Some((8.5, 14.0)));
693 }
694
695 #[test]
696 fn paper_format_inches_unknown() {
697 assert!(paper_format_inches("weird").is_none());
698 }
699}