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 tokio::sync::Mutex;
16use tokio::task::JoinHandle;
17
18use crate::browser::media::ColorScheme;
19use crate::core::engine::{ElementInfo, EngineAdapter, EngineError, EngineType, PdfOptions};
20
21pub 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 pub fn raw_page(&self) -> &CdpPage {
53 &self.page
54 }
55
56 pub fn user_data_dir(&self) -> &PathBuf {
58 &self.user_data_dir
59 }
60
61 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 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 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 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 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 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 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
502fn 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
524fn 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 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}