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 fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
241 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 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
491fn 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
513fn 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 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}