1use allwright_plugin_sdk::{
2 ALLWRIGHT_PLUGIN_API_VERSION, AutomationSessionInfo, BrowserKind, BrowserLaunchInfo,
3 BrowserSessionHandle, ChromeLaunchInfo, ChromeTabInfo, ChromiumBidiMapperInfo, ClickInfo,
4 ElementCountInfo, FillInfo, FocusInfo, HighlightElementsInfo, HoverInfo, PageInfo,
5 PageSessionHandle, PluginCommand, PluginEnvelope, PluginResult, PressKeyInfo, SurfaceFamily,
6 SurfacePlugin, SurfacePluginDescriptor, TabNavigationInfo, TextInfo, WaitForSelectorInfo,
7};
8use std::ffi::{CStr, CString, c_char};
9use std::fs;
10use std::future::Future;
11use std::net::TcpListener;
12use std::path::PathBuf;
13use std::process::Command;
14use std::thread;
15use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH};
16
17use futures_util::{SinkExt, StreamExt};
18use serde_json::{Value, json};
19use std::collections::HashMap;
20use tokio::net::TcpStream;
21use tokio::sync::{Mutex, MutexGuard};
22use tokio::time::{Duration, sleep};
23use tokio_tungstenite::MaybeTlsStream;
24use tokio_tungstenite::WebSocketStream;
25use tokio_tungstenite::connect_async;
26use tokio_tungstenite::tungstenite::Message;
27use std::sync::OnceLock;
28
29#[derive(Debug, Clone, PartialEq)]
30struct DiscoveredElements {
31 count: u32,
32 first_center: Option<ElementCenter>,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36struct ElementCenter {
37 x: f64,
38 y: f64,
39}
40
41const CHROMIUM_BIDI_NPM_VERSION: &str = "17.0.2";
42const CHROMIUM_BIDI_MAPPER_BUNDLE: &str =
43 include_str!("../third_party/chromium-bidi/17.0.2/mapperTab.js");
44
45struct FirefoxSessionState {
46 process_id: u32,
47 connection: BidiConnection,
48}
49
50static FIREFOX_SESSION_REGISTRY: OnceLock<Mutex<HashMap<String, FirefoxSessionState>>> =
51 OnceLock::new();
52static PLUGIN_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
53
54#[derive(Debug, Clone, Copy, Default)]
55pub struct WebPlugin;
56
57impl SurfacePlugin for WebPlugin {
58 fn descriptor(&self) -> SurfacePluginDescriptor {
59 SurfacePluginDescriptor {
60 id: "web",
61 family: SurfaceFamily::Web,
62 version: env!("CARGO_PKG_VERSION"),
63 description: "Web surface plugin for the allwright engine.",
64 }
65 }
66}
67
68pub fn open_chrome_window(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
69 launch_chrome_for_platform(chrome_binary.filter(|value| !value.trim().is_empty()))
70}
71
72pub async fn discover_initial_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
73 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
74
75 for _ in 0..100 {
76 let targets = cdp
77 .send_command("Target.getTargets", json!({}), None)
78 .await?;
79 if let Some(page_target) = targets
80 .get("targetInfos")
81 .and_then(Value::as_array)
82 .and_then(|target_infos| {
83 target_infos.iter().find(|target_info| {
84 target_info.get("type").and_then(Value::as_str) == Some("page")
85 })
86 })
87 {
88 let target_id = required_string(page_target, "/targetId")?;
89 return Ok(ChromeTabInfo {
90 note: "tracked initial Chrome tab via CDP".to_string(),
91 target_id,
92 });
93 }
94
95 sleep(Duration::from_millis(50)).await;
96 }
97
98 Err("timed out waiting for the initial Chrome page target over CDP".to_string())
99}
100
101pub async fn open_chrome_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
102 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
103 let create_target = cdp
104 .send_command(
105 "Target.createTarget",
106 json!({
107 "url": "about:blank",
108 "background": false,
109 }),
110 None,
111 )
112 .await?;
113 let target_id = required_string(&create_target, "/targetId")?;
114
115 Ok(ChromeTabInfo {
116 note: "opened Chrome tab via CDP".to_string(),
117 target_id,
118 })
119}
120
121pub fn close_browser_process(process_id: u32) -> Result<(), String> {
122 block_on_plugin_future(async {
123 let registry = firefox_session_registry();
124 let mut sessions = registry.lock().await;
125 sessions.retain(|_, state| state.process_id != process_id);
126 Ok::<(), String>(())
127 })?;
128 close_browser_process_for_platform(process_id)
129}
130
131pub fn launch_browser(browser_kind: BrowserKind, browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
132 match browser_kind {
133 BrowserKind::Chromium => {
134 let launch = open_chrome_window(browser_binary)?;
135 let initial_page = block_on_plugin_future(async {
136 discover_initial_tab(&launch.cdp_websocket_url).await
137 })?;
138 Ok(BrowserLaunchInfo {
139 browser_kind,
140 browser: launch.browser,
141 note: launch.note,
142 user_data_dir: launch.user_data_dir,
143 process_id: launch.process_id,
144 browser_session: BrowserSessionHandle::Chromium {
145 cdp_websocket_url: launch.cdp_websocket_url,
146 },
147 initial_page: PageInfo {
148 note: initial_page.note,
149 page_session: PageSessionHandle::Chromium {
150 target_id: initial_page.target_id,
151 browsing_context_id: None,
152 },
153 },
154 })
155 }
156 BrowserKind::Firefox => launch_firefox_with_bidi(browser_binary),
157 }
158}
159
160pub async fn open_page(
161 browser_session: &BrowserSessionHandle,
162) -> Result<PageInfo, String> {
163 match browser_session {
164 BrowserSessionHandle::Chromium { cdp_websocket_url } => {
165 let tab = open_chrome_tab(cdp_websocket_url).await?;
166 Ok(PageInfo {
167 note: tab.note,
168 page_session: PageSessionHandle::Chromium {
169 target_id: tab.target_id,
170 browsing_context_id: None,
171 },
172 })
173 }
174 BrowserSessionHandle::Firefox {
175 connection_id, ..
176 } => {
177 let mut sessions = firefox_session_guard(connection_id).await?;
178 let bidi = &mut sessions
179 .get_mut(connection_id)
180 .expect("Firefox session guard validated connection id")
181 .connection;
182 let created = bidi
183 .send_command(
184 "browsingContext.create",
185 json!({
186 "type": "tab",
187 "background": false,
188 }),
189 )
190 .await?;
191 Ok(PageInfo {
192 note: "opened Firefox tab via native WebDriver BiDi".to_string(),
193 page_session: PageSessionHandle::Firefox {
194 browsing_context_id: required_string(&created, "/result/context")?,
195 },
196 })
197 }
198 }
199}
200
201pub async fn close_page(
202 browser_session: &BrowserSessionHandle,
203 page_session: &PageSessionHandle,
204) -> Result<(), String> {
205 match (browser_session, page_session) {
206 (
207 BrowserSessionHandle::Chromium { cdp_websocket_url },
208 PageSessionHandle::Chromium { target_id, .. },
209 ) => close_chrome_tab(cdp_websocket_url, target_id).await,
210 (
211 BrowserSessionHandle::Firefox {
212 connection_id, ..
213 },
214 PageSessionHandle::Firefox {
215 browsing_context_id,
216 },
217 ) => {
218 let mut sessions = firefox_session_guard(connection_id).await?;
219 let bidi = &mut sessions
220 .get_mut(connection_id)
221 .expect("Firefox session guard validated connection id")
222 .connection;
223 bidi.send_command(
224 "browsingContext.close",
225 json!({
226 "context": browsing_context_id,
227 }),
228 )
229 .await?;
230 Ok(())
231 }
232 _ => Err("browser/page backend mismatch while closing page".to_string()),
233 }
234}
235
236pub async fn close_chrome_tab(cdp_websocket_url: &str, target_id: &str) -> Result<(), String> {
237 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
238 let response = cdp
239 .send_command(
240 "Target.closeTarget",
241 json!({
242 "targetId": target_id,
243 }),
244 None,
245 )
246 .await?;
247 let success = response
248 .get("success")
249 .and_then(Value::as_bool)
250 .unwrap_or(false);
251 if success {
252 Ok(())
253 } else {
254 Err(format!(
255 "CDP Target.closeTarget reported unsuccessful close for target {target_id}"
256 ))
257 }
258}
259
260pub async fn navigate_chrome_tab(
261 cdp_websocket_url: &str,
262 target_id: &str,
263 url: &str,
264) -> Result<TabNavigationInfo, String> {
265 if url.trim().is_empty() {
266 return Err("navigate command requires a non-empty url".to_string());
267 }
268
269 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
270 let attach = cdp
271 .send_command(
272 "Target.attachToTarget",
273 json!({
274 "targetId": target_id,
275 "flatten": true,
276 }),
277 None,
278 )
279 .await?;
280 let session_id = required_string(&attach, "/sessionId")?;
281
282 cdp.send_command("Page.enable", json!({}), Some(&session_id))
283 .await?;
284 cdp.navigate_and_wait_for_load(&session_id, url).await?;
285 let frame_tree = cdp
286 .send_command("Page.getFrameTree", json!({}), Some(&session_id))
287 .await?;
288 let browsing_context_id = required_string(&frame_tree, "/frameTree/frame/id")?;
289 cdp.send_command(
290 "Target.detachFromTarget",
291 json!({
292 "sessionId": session_id,
293 }),
294 None,
295 )
296 .await?;
297
298 Ok(TabNavigationInfo {
299 url: url.to_string(),
300 note: "navigated Chrome tab via CDP and observed Page.loadEventFired".to_string(),
301 page_session: PageSessionHandle::Chromium {
302 target_id: target_id.to_string(),
303 browsing_context_id: Some(browsing_context_id),
304 },
305 automation: AutomationSessionInfo {
306 bidi_session_id: String::new(),
307 note: String::new(),
308 mapper_target_id: None,
309 mapper_session_id: None,
310 package_version: None,
311 },
312 })
313}
314
315pub async fn navigate_page(
316 browser_session: &BrowserSessionHandle,
317 page_session: &PageSessionHandle,
318 url: &str,
319) -> Result<TabNavigationInfo, String> {
320 match (browser_session, page_session) {
321 (
322 BrowserSessionHandle::Chromium { cdp_websocket_url },
323 PageSessionHandle::Chromium {
324 target_id,
325 browsing_context_id,
326 },
327 ) => {
328 let navigation = navigate_chrome_tab(cdp_websocket_url, target_id, url).await?;
329 let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
330 cdp_websocket_url,
331 None,
332 browsing_context_id.as_deref(),
333 Some(&navigation.url),
334 )
335 .await?;
336 Ok(TabNavigationInfo {
337 url: navigation.url,
338 note: navigation.note,
339 page_session: PageSessionHandle::Chromium {
340 target_id: target_id.to_string(),
341 browsing_context_id: Some(resolved_browsing_context_id),
342 },
343 automation: AutomationSessionInfo {
344 bidi_session_id: format!("chromium-bidi:{target_id}"),
345 note: format!(
346 "chromium-bidi mapper ready from pinned chromium-bidi@{} after navigation",
347 mapper.package_version
348 ),
349 mapper_target_id: Some(mapper.mapper_target_id),
350 mapper_session_id: Some(mapper.mapper_session_id),
351 package_version: Some(mapper.package_version),
352 },
353 })
354 }
355 (
356 BrowserSessionHandle::Firefox {
357 connection_id,
358 bidi_session_id,
359 },
360 PageSessionHandle::Firefox {
361 browsing_context_id,
362 },
363 ) => {
364 let mut sessions = firefox_session_guard(connection_id).await?;
365 let bidi = &mut sessions
366 .get_mut(connection_id)
367 .expect("Firefox session guard validated connection id")
368 .connection;
369 let result = bidi
370 .send_command(
371 "browsingContext.navigate",
372 json!({
373 "context": browsing_context_id,
374 "url": url,
375 "wait": "complete",
376 }),
377 )
378 .await?;
379 Ok(TabNavigationInfo {
380 url: json_string(&result, "/result/url")?,
381 note: "navigated Firefox tab via native WebDriver BiDi".to_string(),
382 page_session: page_session.clone(),
383 automation: AutomationSessionInfo {
384 bidi_session_id: bidi_session_id.clone(),
385 note: "connected to native Firefox WebDriver BiDi session".to_string(),
386 mapper_target_id: None,
387 mapper_session_id: None,
388 package_version: None,
389 },
390 })
391 }
392 _ => Err("browser/page backend mismatch while navigating".to_string()),
393 }
394}
395
396pub async fn inject_chromium_bidi_mapper(
397 cdp_websocket_url: &str,
398) -> Result<ChromiumBidiMapperInfo, String> {
399 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
400 let mapper_target = cdp
401 .send_command(
402 "Target.createTarget",
403 json!({
404 "url": "about:blank#MAPPER_TARGET",
405 "hidden": true,
406 "background": true,
407 }),
408 None,
409 )
410 .await?;
411 let mapper_target_id = required_string(&mapper_target, "/targetId")?;
412
413 let attached = cdp
414 .send_command(
415 "Target.attachToTarget",
416 json!({
417 "targetId": mapper_target_id,
418 "flatten": true,
419 }),
420 None,
421 )
422 .await?;
423 let mapper_session_id = required_string(&attached, "/sessionId")?;
424
425 cdp.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
426 .await?;
427 cdp.send_command(
428 "Target.exposeDevToolsProtocol",
429 json!({
430 "bindingName": "cdp",
431 "targetId": mapper_target_id,
432 "inheritPermissions": true,
433 }),
434 None,
435 )
436 .await?;
437 cdp.send_command(
438 "Runtime.addBinding",
439 json!({
440 "name": "sendBidiResponse",
441 }),
442 Some(&mapper_session_id),
443 )
444 .await?;
445 cdp.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
446 .await?;
447 cdp.evaluate_expression(
448 &mapper_session_id,
449 &format!(
450 "window.runMapperInstance('{}')",
451 js_single_quote(&mapper_target_id)
452 ),
453 true,
454 )
455 .await?;
456
457 Ok(ChromiumBidiMapperInfo {
458 package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
459 mapper_target_id,
460 mapper_session_id,
461 note: format!(
462 "injected chromium-bidi mapper from pinned published chromium-bidi@{} artifact into hidden mapper target",
463 CHROMIUM_BIDI_NPM_VERSION
464 ),
465 })
466}
467
468pub async fn click_element_via_cdp(
469 cdp_websocket_url: &str,
470 target_id: &str,
471 css_selector: &str,
472) -> Result<ClickInfo, String> {
473 if css_selector.trim().is_empty() {
474 return Err("click_element command requires a non-empty css_selector".to_string());
475 }
476
477 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
478 let session_id = cdp.prepare_page_target_session(target_id).await?;
479 let discovered =
480 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
481 let center = discovered.first_center.ok_or_else(|| {
482 format!("element discovery did not return a clickable center for selector {css_selector}")
483 })?;
484 cdp.dispatch_mouse_click(&session_id, center.x, center.y)
485 .await?;
486 cdp.detach_from_target(&session_id).await?;
487
488 Ok(ClickInfo {
489 css_selector: css_selector.to_string(),
490 note: format!("clicked element via CDP mouse events using css selector {css_selector}"),
491 bidi_session_id: String::new(),
492 })
493}
494
495pub async fn count_elements_via_cdp(
496 cdp_websocket_url: &str,
497 target_id: &str,
498 css_selector: &str,
499) -> Result<ElementCountInfo, String> {
500 if css_selector.trim().is_empty() {
501 return Err("count_elements command requires a non-empty css_selector".to_string());
502 }
503
504 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
505 let session_id = cdp.prepare_page_target_session(target_id).await?;
506 let discovered =
507 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
508 cdp.detach_from_target(&session_id).await?;
509
510 Ok(ElementCountInfo {
511 css_selector: css_selector.to_string(),
512 count: discovered.count,
513 note: format!(
514 "counted {} element(s) matching css selector {css_selector}",
515 discovered.count
516 ),
517 })
518}
519
520pub async fn highlight_elements_via_cdp(
521 cdp_websocket_url: &str,
522 target_id: &str,
523 css_selector: &str,
524 duration_ms: u32,
525) -> Result<HighlightElementsInfo, String> {
526 if css_selector.trim().is_empty() {
527 return Err("highlight_elements command requires a non-empty css_selector".to_string());
528 }
529
530 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
531 let session_id = cdp.prepare_page_target_session(target_id).await?;
532 let discovered =
533 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
534
535 let selector_literal = serde_json::to_string(css_selector)
536 .map_err(|error| format!("failed to serialize css selector for highlight: {error}"))?;
537 let duration_ms = duration_ms.max(1);
538 cdp.evaluate_expression(
539 &session_id,
540 &format!(
541 "(() => {{
542 const selector = {selector_literal};
543 const durationMs = {duration_ms};
544 const elements = Array.from(document.querySelectorAll(selector));
545 for (const element of elements) {{
546 if (!(element instanceof HTMLElement)) {{
547 continue;
548 }}
549 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
550 const priorOutline = element.style.outline;
551 const priorOutlineOffset = element.style.outlineOffset;
552 const priorBackgroundColor = element.style.backgroundColor;
553 element.style.outline = '3px solid #ff5a36';
554 element.style.outlineOffset = '2px';
555 element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
556 window.setTimeout(() => {{
557 element.style.outline = priorOutline;
558 element.style.outlineOffset = priorOutlineOffset;
559 element.style.backgroundColor = priorBackgroundColor;
560 }}, durationMs);
561 }}
562 return elements.length;
563 }})()"
564 ),
565 true,
566 )
567 .await?;
568 cdp.detach_from_target(&session_id).await?;
569
570 Ok(HighlightElementsInfo {
571 css_selector: css_selector.to_string(),
572 count: discovered.count,
573 note: format!(
574 "highlighted {} element(s) matching css selector {css_selector} for {duration_ms}ms",
575 discovered.count
576 ),
577 })
578}
579
580pub async fn focus_element_via_cdp(
581 cdp_websocket_url: &str,
582 target_id: &str,
583 css_selector: &str,
584) -> Result<FocusInfo, String> {
585 require_non_empty_selector("focus_element", css_selector)?;
586 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
587 let session_id = cdp.prepare_page_target_session(target_id).await?;
588 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
589 cdp.detach_from_target(&session_id).await?;
590
591 Ok(FocusInfo {
592 css_selector: css_selector.to_string(),
593 note: format!("focused element matching css selector {css_selector}"),
594 })
595}
596
597pub async fn fill_element_via_cdp(
598 cdp_websocket_url: &str,
599 target_id: &str,
600 css_selector: &str,
601 value: &str,
602) -> Result<FillInfo, String> {
603 require_non_empty_selector("fill_element", css_selector)?;
604 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
605 let session_id = cdp.prepare_page_target_session(target_id).await?;
606 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
607 let selector_literal = json_string_literal(css_selector, "fill selector")?;
608 let value_literal = json_string_literal(value, "fill value")?;
609 cdp.evaluate_expression(
610 &session_id,
611 &dom_set_value_and_dispatch_events_js(&selector_literal, &value_literal),
612 true,
613 )
614 .await?;
615 cdp.detach_from_target(&session_id).await?;
616
617 Ok(FillInfo {
618 css_selector: css_selector.to_string(),
619 value: value.to_string(),
620 note: format!("filled element matching css selector {css_selector}"),
621 })
622}
623
624pub async fn hover_element_via_cdp(
625 cdp_websocket_url: &str,
626 target_id: &str,
627 css_selector: &str,
628) -> Result<HoverInfo, String> {
629 require_non_empty_selector("hover_element", css_selector)?;
630 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
631 let session_id = cdp.prepare_page_target_session(target_id).await?;
632 let discovered =
633 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
634 let center = discovered.first_center.ok_or_else(|| {
635 format!("element discovery did not return a hover center for selector {css_selector}")
636 })?;
637 cdp.dispatch_mouse_move(&session_id, center.x, center.y)
638 .await?;
639 cdp.detach_from_target(&session_id).await?;
640
641 Ok(HoverInfo {
642 css_selector: css_selector.to_string(),
643 note: format!("hovered element matching css selector {css_selector}"),
644 })
645}
646
647pub async fn press_key_via_cdp(
648 cdp_websocket_url: &str,
649 target_id: &str,
650 css_selector: &str,
651 key: &str,
652 text: Option<&str>,
653) -> Result<PressKeyInfo, String> {
654 require_non_empty_selector("press_key", css_selector)?;
655 if key.trim().is_empty() {
656 return Err("press_key command requires a non-empty key".to_string());
657 }
658
659 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
660 let session_id = cdp.prepare_page_target_session(target_id).await?;
661 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
662 cdp.dispatch_key_press(&session_id, key, text).await?;
663 cdp.detach_from_target(&session_id).await?;
664
665 Ok(PressKeyInfo {
666 css_selector: css_selector.to_string(),
667 key: key.to_string(),
668 note: format!("pressed key {key} on element matching css selector {css_selector}"),
669 })
670}
671
672pub async fn get_text_content_via_cdp(
673 cdp_websocket_url: &str,
674 target_id: &str,
675 css_selector: &str,
676) -> Result<TextInfo, String> {
677 get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "textContent")
678 .await
679 .map(|mut info| {
680 info.note = format!("resolved textContent for css selector {css_selector}");
681 info
682 })
683}
684
685pub async fn get_inner_text_via_cdp(
686 cdp_websocket_url: &str,
687 target_id: &str,
688 css_selector: &str,
689) -> Result<TextInfo, String> {
690 get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "innerText")
691 .await
692 .map(|mut info| {
693 info.note = format!("resolved innerText for css selector {css_selector}");
694 info
695 })
696}
697
698pub async fn wait_for_selector_via_cdp(
699 cdp_websocket_url: &str,
700 target_id: &str,
701 css_selector: &str,
702 visible: bool,
703) -> Result<WaitForSelectorInfo, String> {
704 require_non_empty_selector("wait_for_selector", css_selector)?;
705 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
706 let session_id = cdp.prepare_page_target_session(target_id).await?;
707 let discovered =
708 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
709 let success = if visible {
710 discovered.first_center.is_some()
711 } else {
712 discovered.count > 0
713 };
714 cdp.detach_from_target(&session_id).await?;
715
716 if !success {
717 return Err(if visible {
718 format!("no visible element matches css selector {css_selector}")
719 } else {
720 format!("no element matches css selector {css_selector}")
721 });
722 }
723
724 Ok(WaitForSelectorInfo {
725 css_selector: css_selector.to_string(),
726 visible,
727 note: if visible {
728 format!("visible element matched css selector {css_selector}")
729 } else {
730 format!("element matched css selector {css_selector}")
731 },
732 })
733}
734
735pub async fn click_element(
736 browser_session: &BrowserSessionHandle,
737 page_session: &PageSessionHandle,
738 css_selector: &str,
739) -> Result<ClickInfo, String> {
740 match (browser_session, page_session) {
741 (
742 BrowserSessionHandle::Chromium { cdp_websocket_url },
743 PageSessionHandle::Chromium { target_id, .. },
744 ) => click_element_via_cdp(cdp_websocket_url, target_id, css_selector).await,
745 (
746 BrowserSessionHandle::Firefox {
747 connection_id,
748 bidi_session_id,
749 },
750 PageSessionHandle::Firefox {
751 browsing_context_id,
752 },
753 ) => {
754 firefox_require_selector(css_selector, "click_element")?;
755 let mut sessions = firefox_session_guard(connection_id).await?;
756 let bidi = &mut sessions
757 .get_mut(connection_id)
758 .expect("Firefox session guard validated connection id")
759 .connection;
760 firefox_evaluate_void(
761 bidi,
762 browsing_context_id,
763 &format!(
764 "(() => {{
765 const selector = {};
766 const element = document.querySelector(selector);
767 if (!element) {{
768 throw new Error(`No element matches selector: ${{selector}}`);
769 }}
770 if (!(element instanceof HTMLElement)) {{
771 throw new Error(`Element is not clickable: ${{selector}}`);
772 }}
773 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
774 element.click();
775 }})()",
776 json_string_literal(css_selector, "click selector")?,
777 ),
778 )
779 .await?;
780 Ok(ClickInfo {
781 css_selector: css_selector.to_string(),
782 note: format!("clicked element via Firefox WebDriver BiDi using css selector {css_selector}"),
783 bidi_session_id: bidi_session_id.clone(),
784 })
785 }
786 _ => Err("browser/page backend mismatch while clicking".to_string()),
787 }
788}
789
790pub async fn count_elements(
791 browser_session: &BrowserSessionHandle,
792 page_session: &PageSessionHandle,
793 css_selector: &str,
794) -> Result<ElementCountInfo, String> {
795 match (browser_session, page_session) {
796 (
797 BrowserSessionHandle::Chromium { cdp_websocket_url },
798 PageSessionHandle::Chromium { target_id, .. },
799 ) => count_elements_via_cdp(cdp_websocket_url, target_id, css_selector).await,
800 (
801 BrowserSessionHandle::Firefox {
802 connection_id, ..
803 },
804 PageSessionHandle::Firefox {
805 browsing_context_id,
806 },
807 ) => {
808 firefox_require_selector(css_selector, "count_elements")?;
809 let mut sessions = firefox_session_guard(connection_id).await?;
810 let bidi = &mut sessions
811 .get_mut(connection_id)
812 .expect("Firefox session guard validated connection id")
813 .connection;
814 let count = firefox_evaluate_u32(
815 bidi,
816 browsing_context_id,
817 &format!(
818 "(() => document.querySelectorAll({}).length)()",
819 json_string_literal(css_selector, "count selector")?,
820 ),
821 )
822 .await?;
823 Ok(ElementCountInfo {
824 css_selector: css_selector.to_string(),
825 count,
826 note: format!("counted {count} element(s) matching css selector {css_selector}"),
827 })
828 }
829 _ => Err("browser/page backend mismatch while counting elements".to_string()),
830 }
831}
832
833pub async fn highlight_elements(
834 browser_session: &BrowserSessionHandle,
835 page_session: &PageSessionHandle,
836 css_selector: &str,
837 duration_ms: u64,
838) -> Result<HighlightElementsInfo, String> {
839 match (browser_session, page_session) {
840 (
841 BrowserSessionHandle::Chromium { cdp_websocket_url },
842 PageSessionHandle::Chromium { target_id, .. },
843 ) => highlight_elements_via_cdp(
844 cdp_websocket_url,
845 target_id,
846 css_selector,
847 u32::try_from(duration_ms).unwrap_or(u32::MAX),
848 )
849 .await,
850 (
851 BrowserSessionHandle::Firefox {
852 connection_id, ..
853 },
854 PageSessionHandle::Firefox {
855 browsing_context_id,
856 },
857 ) => {
858 firefox_require_selector(css_selector, "highlight_elements")?;
859 let duration_ms = u32::try_from(duration_ms).unwrap_or(u32::MAX).max(1);
860 let mut sessions = firefox_session_guard(connection_id).await?;
861 let bidi = &mut sessions
862 .get_mut(connection_id)
863 .expect("Firefox session guard validated connection id")
864 .connection;
865 let count = firefox_evaluate_u32(
866 bidi,
867 browsing_context_id,
868 &format!(
869 "(() => {{
870 const selector = {};
871 const durationMs = {duration_ms};
872 const elements = Array.from(document.querySelectorAll(selector));
873 for (const element of elements) {{
874 if (!(element instanceof HTMLElement)) {{
875 continue;
876 }}
877 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
878 const priorOutline = element.style.outline;
879 const priorOutlineOffset = element.style.outlineOffset;
880 const priorBackgroundColor = element.style.backgroundColor;
881 element.style.outline = '3px solid #ff5a36';
882 element.style.outlineOffset = '2px';
883 element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
884 window.setTimeout(() => {{
885 element.style.outline = priorOutline;
886 element.style.outlineOffset = priorOutlineOffset;
887 element.style.backgroundColor = priorBackgroundColor;
888 }}, durationMs);
889 }}
890 return elements.length;
891 }})()",
892 json_string_literal(css_selector, "highlight selector")?,
893 ),
894 )
895 .await?;
896 Ok(HighlightElementsInfo {
897 css_selector: css_selector.to_string(),
898 count,
899 note: format!(
900 "highlighted {count} element(s) matching css selector {css_selector} for {duration_ms}ms"
901 ),
902 })
903 }
904 _ => Err("browser/page backend mismatch while highlighting elements".to_string()),
905 }
906}
907
908pub async fn focus_element(
909 browser_session: &BrowserSessionHandle,
910 page_session: &PageSessionHandle,
911 css_selector: &str,
912) -> Result<FocusInfo, String> {
913 match (browser_session, page_session) {
914 (
915 BrowserSessionHandle::Chromium { cdp_websocket_url },
916 PageSessionHandle::Chromium { target_id, .. },
917 ) => focus_element_via_cdp(cdp_websocket_url, target_id, css_selector).await,
918 (
919 BrowserSessionHandle::Firefox {
920 connection_id, ..
921 },
922 PageSessionHandle::Firefox {
923 browsing_context_id,
924 },
925 ) => {
926 firefox_require_selector(css_selector, "focus_element")?;
927 let mut sessions = firefox_session_guard(connection_id).await?;
928 let bidi = &mut sessions
929 .get_mut(connection_id)
930 .expect("Firefox session guard validated connection id")
931 .connection;
932 firefox_evaluate_void(
933 bidi,
934 browsing_context_id,
935 &format!(
936 "(() => {{
937 const selector = {};
938 const element = document.querySelector(selector);
939 if (!element) {{
940 throw new Error(`No element matches selector: ${{selector}}`);
941 }}
942 if (!(element instanceof HTMLElement)) {{
943 throw new Error(`Element is not focusable: ${{selector}}`);
944 }}
945 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
946 element.focus();
947 }})()",
948 json_string_literal(css_selector, "focus selector")?,
949 ),
950 )
951 .await?;
952 Ok(FocusInfo {
953 css_selector: css_selector.to_string(),
954 note: format!("focused element matching css selector {css_selector}"),
955 })
956 }
957 _ => Err("browser/page backend mismatch while focusing element".to_string()),
958 }
959}
960
961pub async fn fill_element(
962 browser_session: &BrowserSessionHandle,
963 page_session: &PageSessionHandle,
964 css_selector: &str,
965 value: &str,
966) -> Result<FillInfo, String> {
967 match (browser_session, page_session) {
968 (
969 BrowserSessionHandle::Chromium { cdp_websocket_url },
970 PageSessionHandle::Chromium { target_id, .. },
971 ) => fill_element_via_cdp(cdp_websocket_url, target_id, css_selector, value).await,
972 (
973 BrowserSessionHandle::Firefox {
974 connection_id, ..
975 },
976 PageSessionHandle::Firefox {
977 browsing_context_id,
978 },
979 ) => {
980 firefox_require_selector(css_selector, "fill_element")?;
981 let mut sessions = firefox_session_guard(connection_id).await?;
982 let bidi = &mut sessions
983 .get_mut(connection_id)
984 .expect("Firefox session guard validated connection id")
985 .connection;
986 firefox_evaluate_void(
987 bidi,
988 browsing_context_id,
989 &dom_set_value_and_dispatch_events_js(
990 &json_string_literal(css_selector, "fill selector")?,
991 &json_string_literal(value, "fill value")?,
992 ),
993 )
994 .await?;
995 Ok(FillInfo {
996 css_selector: css_selector.to_string(),
997 value: value.to_string(),
998 note: format!("filled element matching css selector {css_selector}"),
999 })
1000 }
1001 _ => Err("browser/page backend mismatch while filling element".to_string()),
1002 }
1003}
1004
1005pub async fn hover_element(
1006 browser_session: &BrowserSessionHandle,
1007 page_session: &PageSessionHandle,
1008 css_selector: &str,
1009) -> Result<HoverInfo, String> {
1010 match (browser_session, page_session) {
1011 (
1012 BrowserSessionHandle::Chromium { cdp_websocket_url },
1013 PageSessionHandle::Chromium { target_id, .. },
1014 ) => hover_element_via_cdp(cdp_websocket_url, target_id, css_selector).await,
1015 (
1016 BrowserSessionHandle::Firefox {
1017 connection_id, ..
1018 },
1019 PageSessionHandle::Firefox {
1020 browsing_context_id,
1021 },
1022 ) => {
1023 firefox_require_selector(css_selector, "hover_element")?;
1024 let mut sessions = firefox_session_guard(connection_id).await?;
1025 let bidi = &mut sessions
1026 .get_mut(connection_id)
1027 .expect("Firefox session guard validated connection id")
1028 .connection;
1029 firefox_evaluate_void(
1030 bidi,
1031 browsing_context_id,
1032 &format!(
1033 "(() => {{
1034 const selector = {};
1035 const element = document.querySelector(selector);
1036 if (!element) {{
1037 throw new Error(`No element matches selector: ${{selector}}`);
1038 }}
1039 if (!(element instanceof HTMLElement)) {{
1040 throw new Error(`Element is not hoverable: ${{selector}}`);
1041 }}
1042 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
1043 for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) {{
1044 element.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, view: window }}));
1045 }}
1046 }})()",
1047 json_string_literal(css_selector, "hover selector")?,
1048 ),
1049 )
1050 .await?;
1051 Ok(HoverInfo {
1052 css_selector: css_selector.to_string(),
1053 note: format!("hovered element matching css selector {css_selector}"),
1054 })
1055 }
1056 _ => Err("browser/page backend mismatch while hovering element".to_string()),
1057 }
1058}
1059
1060pub async fn press_key(
1061 browser_session: &BrowserSessionHandle,
1062 page_session: &PageSessionHandle,
1063 css_selector: &str,
1064 key: &str,
1065 text: Option<&str>,
1066) -> Result<PressKeyInfo, String> {
1067 match (browser_session, page_session) {
1068 (
1069 BrowserSessionHandle::Chromium { cdp_websocket_url },
1070 PageSessionHandle::Chromium { target_id, .. },
1071 ) => press_key_via_cdp(cdp_websocket_url, target_id, css_selector, key, text).await,
1072 (
1073 BrowserSessionHandle::Firefox {
1074 connection_id, ..
1075 },
1076 PageSessionHandle::Firefox {
1077 browsing_context_id,
1078 },
1079 ) => {
1080 firefox_require_selector(css_selector, "press_key")?;
1081 if key.trim().is_empty() {
1082 return Err("press_key command requires a non-empty key".to_string());
1083 }
1084 let mut sessions = firefox_session_guard(connection_id).await?;
1085 let bidi = &mut sessions
1086 .get_mut(connection_id)
1087 .expect("Firefox session guard validated connection id")
1088 .connection;
1089 firefox_evaluate_void(
1090 bidi,
1091 browsing_context_id,
1092 &format!(
1093 "(() => {{
1094 const selector = {};
1095 const key = {};
1096 const text = {};
1097 const element = document.querySelector(selector);
1098 if (!element) {{
1099 throw new Error(`No element matches selector: ${{selector}}`);
1100 }}
1101 if (!(element instanceof HTMLElement)) {{
1102 throw new Error(`Element is not keyboard-targetable: ${{selector}}`);
1103 }}
1104 element.focus();
1105 element.dispatchEvent(new KeyboardEvent('keydown', {{ key, bubbles: true }}));
1106 if (text && 'value' in element) {{
1107 element.value += text;
1108 element.dispatchEvent(new Event('input', {{ bubbles: true }}));
1109 element.dispatchEvent(new Event('change', {{ bubbles: true }}));
1110 }}
1111 element.dispatchEvent(new KeyboardEvent('keyup', {{ key, bubbles: true }}));
1112 }})()",
1113 json_string_literal(css_selector, "press selector")?,
1114 json_string_literal(key, "press key")?,
1115 json_string_literal(text.unwrap_or(""), "press text")?,
1116 ),
1117 )
1118 .await?;
1119 Ok(PressKeyInfo {
1120 css_selector: css_selector.to_string(),
1121 key: key.to_string(),
1122 note: format!("pressed key {key} on element matching css selector {css_selector}"),
1123 })
1124 }
1125 _ => Err("browser/page backend mismatch while pressing a key".to_string()),
1126 }
1127}
1128
1129pub async fn get_text_content(
1130 browser_session: &BrowserSessionHandle,
1131 page_session: &PageSessionHandle,
1132 css_selector: &str,
1133) -> Result<TextInfo, String> {
1134 match (browser_session, page_session) {
1135 (
1136 BrowserSessionHandle::Chromium { cdp_websocket_url },
1137 PageSessionHandle::Chromium { target_id, .. },
1138 ) => get_text_content_via_cdp(cdp_websocket_url, target_id, css_selector).await,
1139 (
1140 BrowserSessionHandle::Firefox {
1141 connection_id, ..
1142 },
1143 PageSessionHandle::Firefox {
1144 browsing_context_id,
1145 },
1146 ) => firefox_get_text(connection_id, browsing_context_id, css_selector, "textContent").await,
1147 _ => Err("browser/page backend mismatch while resolving textContent".to_string()),
1148 }
1149}
1150
1151pub async fn get_inner_text(
1152 browser_session: &BrowserSessionHandle,
1153 page_session: &PageSessionHandle,
1154 css_selector: &str,
1155) -> Result<TextInfo, String> {
1156 match (browser_session, page_session) {
1157 (
1158 BrowserSessionHandle::Chromium { cdp_websocket_url },
1159 PageSessionHandle::Chromium { target_id, .. },
1160 ) => get_inner_text_via_cdp(cdp_websocket_url, target_id, css_selector).await,
1161 (
1162 BrowserSessionHandle::Firefox {
1163 connection_id, ..
1164 },
1165 PageSessionHandle::Firefox {
1166 browsing_context_id,
1167 },
1168 ) => firefox_get_text(connection_id, browsing_context_id, css_selector, "innerText").await,
1169 _ => Err("browser/page backend mismatch while resolving innerText".to_string()),
1170 }
1171}
1172
1173pub async fn wait_for_selector(
1174 browser_session: &BrowserSessionHandle,
1175 page_session: &PageSessionHandle,
1176 css_selector: &str,
1177 visible: bool,
1178) -> Result<WaitForSelectorInfo, String> {
1179 match (browser_session, page_session) {
1180 (
1181 BrowserSessionHandle::Chromium { cdp_websocket_url },
1182 PageSessionHandle::Chromium { target_id, .. },
1183 ) => wait_for_selector_via_cdp(cdp_websocket_url, target_id, css_selector, visible).await,
1184 (
1185 BrowserSessionHandle::Firefox {
1186 connection_id, ..
1187 },
1188 PageSessionHandle::Firefox {
1189 browsing_context_id,
1190 },
1191 ) => {
1192 firefox_require_selector(css_selector, "wait_for_selector")?;
1193 let mut sessions = firefox_session_guard(connection_id).await?;
1194 let bidi = &mut sessions
1195 .get_mut(connection_id)
1196 .expect("Firefox session guard validated connection id")
1197 .connection;
1198 let success = firefox_evaluate_bool(
1199 bidi,
1200 browsing_context_id,
1201 &format!(
1202 "(() => {{
1203 const selector = {};
1204 const element = document.querySelector(selector);
1205 if (!element) {{
1206 return false;
1207 }}
1208 if (!{}) {{
1209 return true;
1210 }}
1211 if (!(element instanceof Element)) {{
1212 return false;
1213 }}
1214 const rect = element.getBoundingClientRect();
1215 return !!(rect.width && rect.height);
1216 }})()",
1217 json_string_literal(css_selector, "wait selector")?,
1218 if visible { "true" } else { "false" },
1219 ),
1220 )
1221 .await?;
1222 if !success {
1223 return Err(if visible {
1224 format!("no visible element matches css selector {css_selector}")
1225 } else {
1226 format!("no element matches css selector {css_selector}")
1227 });
1228 }
1229 Ok(WaitForSelectorInfo {
1230 css_selector: css_selector.to_string(),
1231 visible,
1232 note: if visible {
1233 format!("visible element matched css selector {css_selector}")
1234 } else {
1235 format!("element matched css selector {css_selector}")
1236 },
1237 })
1238 }
1239 _ => Err("browser/page backend mismatch while waiting for selector".to_string()),
1240 }
1241}
1242
1243async fn discover_elements_via_cdp(
1244 cdp: &mut CdpConnection,
1245 session_id: &str,
1246 css_selector: &str,
1247 require_match: bool,
1248 focus_first_match: bool,
1249) -> Result<DiscoveredElements, String> {
1250 let selector_literal = serde_json::to_string(css_selector)
1251 .map_err(|error| format!("failed to serialize css selector for discovery: {error}"))?;
1252 let expression = format!(
1253 "(() => {{
1254 const selector = {selector_literal};
1255 const elements = Array.from(document.querySelectorAll(selector));
1256 const first = elements[0] ?? null;
1257 if (first) {{
1258 first.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
1259 if ({focus_first_match} && first instanceof HTMLElement) {{
1260 first.focus();
1261 }}
1262 }}
1263 if ({require_match} && !first) {{
1264 throw new Error(`No element matches selector: ${{selector}}`);
1265 }}
1266 if (first && !(first instanceof Element)) {{
1267 throw new Error(`Selector did not resolve to a DOM Element: ${{selector}}`);
1268 }}
1269 const rect = first ? first.getBoundingClientRect() : null;
1270 if (first && (!rect.width || !rect.height)) {{
1271 throw new Error(`Element matched by selector has zero size: ${{selector}}`);
1272 }}
1273 return {{
1274 count: elements.length,
1275 first: rect ? {{
1276 x: rect.left + (rect.width / 2),
1277 y: rect.top + (rect.height / 2)
1278 }} : null
1279 }};
1280 }})()"
1281 );
1282 let result = cdp
1283 .evaluate_expression(session_id, &expression, true)
1284 .await?;
1285 let count = json_u32(&result, "/result/value/count")?;
1286 let first_center = if result.pointer("/result/value/first").is_some()
1287 && !result
1288 .pointer("/result/value/first")
1289 .is_some_and(Value::is_null)
1290 {
1291 Some(ElementCenter {
1292 x: json_f64(&result, "/result/value/first/x")?,
1293 y: json_f64(&result, "/result/value/first/y")?,
1294 })
1295 } else {
1296 None
1297 };
1298
1299 Ok(DiscoveredElements {
1300 count,
1301 first_center,
1302 })
1303}
1304
1305async fn get_text_via_cdp(
1306 cdp_websocket_url: &str,
1307 target_id: &str,
1308 css_selector: &str,
1309 property: &str,
1310) -> Result<TextInfo, String> {
1311 require_non_empty_selector("get_text", css_selector)?;
1312 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
1313 let session_id = cdp.prepare_page_target_session(target_id).await?;
1314 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
1315 let selector_literal = json_string_literal(css_selector, "text selector")?;
1316 let property_literal = json_string_literal(property, "text property")?;
1317 let result = cdp
1318 .evaluate_expression(
1319 &session_id,
1320 &format!(
1321 "(() => {{
1322 const selector = {selector_literal};
1323 const property = {property_literal};
1324 const element = document.querySelector(selector);
1325 if (!element) {{
1326 throw new Error(`No element matches selector: ${{selector}}`);
1327 }}
1328 const value = element[property];
1329 return typeof value === 'string' ? value : '';
1330 }})()"
1331 ),
1332 true,
1333 )
1334 .await?;
1335 cdp.detach_from_target(&session_id).await?;
1336
1337 Ok(TextInfo {
1338 css_selector: css_selector.to_string(),
1339 text: json_string(&result, "/result/value")?,
1340 note: String::new(),
1341 })
1342}
1343
1344fn dom_set_value_and_dispatch_events_js(
1345 selector_literal: &str,
1346 value_literal: &str,
1347) -> String {
1348 format!(
1349 "(() => {{
1350 const selector = {selector_literal};
1351 const nextValue = {value_literal};
1352 const element = document.querySelector(selector);
1353 if (!element) {{
1354 throw new Error(`No element matches selector: ${{selector}}`);
1355 }}
1356 if (!(element instanceof HTMLElement)) {{
1357 throw new Error(`Element is not editable: ${{selector}}`);
1358 }}
1359
1360 element.focus();
1361
1362 if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {{
1363 const prototype = element instanceof HTMLInputElement
1364 ? HTMLInputElement.prototype
1365 : HTMLTextAreaElement.prototype;
1366 const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
1367 const setter = descriptor?.set;
1368 if (!setter) {{
1369 throw new Error(`Element value setter is unavailable: ${{selector}}`);
1370 }}
1371 setter.call(element, nextValue);
1372 }} else if (element.isContentEditable) {{
1373 element.textContent = nextValue;
1374 }} else {{
1375 throw new Error(`Element does not support fill: ${{selector}}`);
1376 }}
1377
1378 const inputEvent = typeof InputEvent === 'function'
1379 ? new InputEvent('input', {{
1380 bubbles: true,
1381 composed: true,
1382 data: nextValue,
1383 inputType: 'insertText'
1384 }})
1385 : new Event('input', {{ bubbles: true, composed: true }});
1386 element.dispatchEvent(inputEvent);
1387 element.dispatchEvent(new Event('change', {{ bubbles: true, composed: true }}));
1388 return true;
1389 }})()"
1390 )
1391}
1392
1393pub async fn resolve_bidi_context_for_tab(
1394 cdp_websocket_url: &str,
1395 existing_mapper_target_id: Option<&str>,
1396 existing_context_id: Option<&str>,
1397 current_url: Option<&str>,
1398) -> Result<(String, ChromiumBidiMapperInfo), String> {
1399 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
1400 let mapper = cdp
1401 .ensure_chromium_bidi_mapper(existing_mapper_target_id)
1402 .await?;
1403 let context_id = cdp
1404 .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, current_url)
1405 .await?;
1406
1407 Ok((
1408 context_id,
1409 ChromiumBidiMapperInfo {
1410 package_version: mapper.package_version,
1411 mapper_target_id: mapper.mapper_target_id,
1412 mapper_session_id: mapper.mapper_session_id,
1413 note: "resolved BiDi browsing context for tab".to_string(),
1414 },
1415 ))
1416}
1417
1418#[cfg(target_os = "macos")]
1419fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1420 let browser_binary = chrome_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
1421 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string()
1422 });
1423 launch_chrome_with_cdp(&browser_binary)
1424}
1425
1426#[cfg(target_os = "linux")]
1427fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1428 if let Some(chrome_binary) = chrome_binary {
1429 return launch_chrome_with_cdp(chrome_binary);
1430 }
1431
1432 let candidates = ["google-chrome", "chromium", "chromium-browser"];
1433
1434 for candidate in candidates {
1435 match launch_chrome_with_cdp(candidate) {
1436 Ok(launch_info) => return Ok(launch_info),
1437 Err(error) => {
1438 if error.contains("No such file or directory")
1439 || error.contains("os error 2")
1440 || error.contains("not found")
1441 {
1442 continue;
1443 }
1444 return Err(format!("failed to launch {candidate}: {error}"));
1445 }
1446 }
1447 }
1448
1449 Err("could not find a Chrome-compatible browser binary".to_string())
1450}
1451
1452#[cfg(target_os = "windows")]
1453fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1454 if let Some(chrome_binary) = chrome_binary {
1455 return launch_chrome_with_cdp(chrome_binary);
1456 }
1457
1458 launch_chrome_with_cdp("chrome")
1459}
1460
1461#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1462fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1463 match chrome_binary {
1464 Some(chrome_binary) => launch_chrome_with_cdp(chrome_binary),
1465 None => Err("Chrome launching is not supported on this platform yet".to_string()),
1466 }
1467}
1468
1469fn launch_chrome_with_cdp(browser_binary: &str) -> Result<ChromeLaunchInfo, String> {
1470 let user_data_dir = create_chrome_user_data_dir()?;
1471 let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
1472
1473 let child = Command::new(browser_binary)
1474 .args([
1475 "--new-window",
1476 "--remote-debugging-port=0",
1477 "--no-first-run",
1478 "--no-default-browser-check",
1479 "--disable-sync",
1480 &format!("--user-data-dir={user_data_dir_str}"),
1481 ])
1482 .spawn()
1483 .map_err(|error| format!("failed to launch Chrome binary {browser_binary}: {error}"))?;
1484 let process_id = child.id();
1485
1486 let cdp_websocket_url = wait_for_cdp_endpoint(&user_data_dir)?;
1487
1488 Ok(ChromeLaunchInfo {
1489 browser: browser_binary.to_string(),
1490 note: "launched Chrome window with CDP enabled".to_string(),
1491 cdp_websocket_url,
1492 user_data_dir: user_data_dir_str,
1493 process_id,
1494 })
1495}
1496
1497fn create_chrome_user_data_dir() -> Result<PathBuf, String> {
1498 let timestamp = SystemTime::now()
1499 .duration_since(UNIX_EPOCH)
1500 .map_err(|error| format!("failed to compute timestamp: {error}"))?
1501 .as_millis();
1502 let pid = std::process::id();
1503 let dir = std::env::temp_dir().join(format!("allwright-chrome-{pid}-{timestamp}"));
1504 fs::create_dir_all(&dir).map_err(|error| {
1505 format!(
1506 "failed to create Chrome user data dir {}: {error}",
1507 dir.display()
1508 )
1509 })?;
1510 Ok(dir)
1511}
1512
1513fn wait_for_cdp_endpoint(user_data_dir: &std::path::Path) -> Result<String, String> {
1514 let active_port_file = user_data_dir.join("DevToolsActivePort");
1515
1516 for _ in 0..100 {
1517 if active_port_file.exists() {
1518 let contents = fs::read_to_string(&active_port_file).map_err(|error| {
1519 format!(
1520 "failed to read DevToolsActivePort file {}: {error}",
1521 active_port_file.display()
1522 )
1523 })?;
1524 return parse_devtools_active_port(&contents);
1525 }
1526 thread::sleep(StdDuration::from_millis(50));
1527 }
1528
1529 Err(format!(
1530 "timed out waiting for DevToolsActivePort in {}",
1531 user_data_dir.display()
1532 ))
1533}
1534
1535fn parse_devtools_active_port(contents: &str) -> Result<String, String> {
1536 let mut lines = contents.lines();
1537 let port = lines
1538 .next()
1539 .map(str::trim)
1540 .filter(|value| !value.is_empty())
1541 .ok_or_else(|| "DevToolsActivePort missing port line".to_string())?;
1542 let browser_path = lines
1543 .next()
1544 .map(str::trim)
1545 .filter(|value| !value.is_empty())
1546 .ok_or_else(|| "DevToolsActivePort missing websocket path line".to_string())?;
1547
1548 Ok(format!("ws://127.0.0.1:{port}{browser_path}"))
1549}
1550
1551#[cfg(target_os = "macos")]
1552fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1553 let browser_binary = browser_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
1554 "/Applications/Firefox.app/Contents/MacOS/firefox".to_string()
1555 });
1556 launch_firefox_with_remote_agent(&browser_binary)
1557}
1558
1559#[cfg(target_os = "linux")]
1560fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1561 if let Some(browser_binary) = browser_binary {
1562 return launch_firefox_with_remote_agent(browser_binary);
1563 }
1564
1565 for candidate in ["firefox", "firefox-esr"] {
1566 match launch_firefox_with_remote_agent(candidate) {
1567 Ok(launch_info) => return Ok(launch_info),
1568 Err(error)
1569 if error.contains("No such file or directory")
1570 || error.contains("os error 2")
1571 || error.contains("not found") => {}
1572 Err(error) => return Err(format!("failed to launch {candidate}: {error}")),
1573 }
1574 }
1575
1576 Err("could not find a Firefox browser binary".to_string())
1577}
1578
1579#[cfg(target_os = "windows")]
1580fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1581 let browser_binary = browser_binary.unwrap_or("firefox");
1582 launch_firefox_with_remote_agent(browser_binary)
1583}
1584
1585#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1586fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1587 match browser_binary {
1588 Some(browser_binary) => launch_firefox_with_remote_agent(browser_binary),
1589 None => Err("Firefox launching is not supported on this platform yet".to_string()),
1590 }
1591}
1592
1593fn launch_firefox_with_remote_agent(browser_binary: &str) -> Result<BrowserLaunchInfo, String> {
1594 let user_data_dir = create_firefox_user_data_dir()?;
1595 let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
1596 let remote_port = choose_loopback_port()?;
1597
1598 let child = Command::new(browser_binary)
1599 .args([
1600 "--new-instance",
1601 "--no-remote",
1602 "--remote-debugging-port",
1603 &remote_port.to_string(),
1604 "--profile",
1605 &user_data_dir_str,
1606 "about:blank",
1607 ])
1608 .spawn()
1609 .map_err(|error| format!("failed to launch Firefox binary {browser_binary}: {error}"))?;
1610 let process_id = child.id();
1611
1612 let bootstrap_ws_url = format!("ws://127.0.0.1:{remote_port}/session");
1613 let (connection_id, bidi_session_id, initial_context_id) = block_on_plugin_future(async {
1614 let mut bidi = wait_for_bidi_endpoint(&bootstrap_ws_url).await?;
1615 let session = bidi
1616 .send_command(
1617 "session.new",
1618 json!({
1619 "capabilities": {
1620 "alwaysMatch": {
1621 "acceptInsecureCerts": true
1622 }
1623 }
1624 }),
1625 )
1626 .await?;
1627 let session_id = required_string(&session, "/result/sessionId")?;
1628 let initial_context_id = bidi.wait_for_top_level_context().await?;
1629 let connection_id = format!("firefox-bidi-{process_id}-{session_id}");
1630 firefox_session_registry().lock().await.insert(
1631 connection_id.clone(),
1632 FirefoxSessionState {
1633 process_id,
1634 connection: bidi,
1635 },
1636 );
1637 Ok((connection_id, session_id, initial_context_id))
1638 })?;
1639
1640 Ok(BrowserLaunchInfo {
1641 browser_kind: BrowserKind::Firefox,
1642 browser: browser_binary.to_string(),
1643 note: "launched Firefox window with native WebDriver BiDi enabled".to_string(),
1644 user_data_dir: user_data_dir_str,
1645 process_id,
1646 browser_session: BrowserSessionHandle::Firefox {
1647 connection_id,
1648 bidi_session_id: bidi_session_id.clone(),
1649 },
1650 initial_page: PageInfo {
1651 note: "tracked initial Firefox tab via native WebDriver BiDi".to_string(),
1652 page_session: PageSessionHandle::Firefox {
1653 browsing_context_id: initial_context_id,
1654 },
1655 },
1656 })
1657}
1658
1659fn create_firefox_user_data_dir() -> Result<PathBuf, String> {
1660 let timestamp = SystemTime::now()
1661 .duration_since(UNIX_EPOCH)
1662 .map_err(|error| format!("failed to compute timestamp: {error}"))?
1663 .as_millis();
1664 let pid = std::process::id();
1665 let dir = std::env::temp_dir().join(format!("allwright-firefox-{pid}-{timestamp}"));
1666 fs::create_dir_all(&dir).map_err(|error| {
1667 format!(
1668 "failed to create Firefox profile dir {}: {error}",
1669 dir.display()
1670 )
1671 })?;
1672 Ok(dir)
1673}
1674
1675fn choose_loopback_port() -> Result<u16, String> {
1676 let listener = TcpListener::bind("127.0.0.1:0")
1677 .map_err(|error| format!("failed to reserve a loopback port for Firefox: {error}"))?;
1678 let port = listener
1679 .local_addr()
1680 .map_err(|error| format!("failed to read reserved loopback port: {error}"))?
1681 .port();
1682 drop(listener);
1683 Ok(port)
1684}
1685
1686async fn wait_for_bidi_endpoint(ws_url: &str) -> Result<BidiConnection, String> {
1687 let mut last_error = None;
1688 for _ in 0..100 {
1689 match BidiConnection::connect(ws_url).await {
1690 Ok(connection) => return Ok(connection),
1691 Err(error) => {
1692 last_error = Some(error);
1693 sleep(Duration::from_millis(50)).await;
1694 }
1695 }
1696 }
1697
1698 Err(format!(
1699 "timed out waiting for WebDriver BiDi endpoint {ws_url}: {}",
1700 last_error.unwrap_or_else(|| "unknown error".to_string())
1701 ))
1702}
1703
1704fn firefox_session_registry() -> &'static Mutex<HashMap<String, FirefoxSessionState>> {
1705 FIREFOX_SESSION_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1706}
1707
1708async fn firefox_session_guard(
1709 connection_id: &str,
1710) -> Result<MutexGuard<'static, HashMap<String, FirefoxSessionState>>, String> {
1711 let registry = firefox_session_registry();
1712 let sessions = registry.lock().await;
1713 if sessions.contains_key(connection_id) {
1714 Ok(sessions)
1715 } else {
1716 Err(format!(
1717 "Firefox automation session {connection_id} is not available"
1718 ))
1719 }
1720}
1721
1722fn firefox_require_selector(css_selector: &str, command_name: &str) -> Result<(), String> {
1723 if css_selector.trim().is_empty() {
1724 Err(format!(
1725 "{command_name} command requires a non-empty css_selector"
1726 ))
1727 } else {
1728 Ok(())
1729 }
1730}
1731
1732async fn firefox_evaluate_json(
1733 bidi: &mut BidiConnection,
1734 browsing_context_id: &str,
1735 expression: &str,
1736) -> Result<Value, String> {
1737 let result = bidi
1738 .send_command(
1739 "script.evaluate",
1740 json!({
1741 "expression": expression,
1742 "target": {
1743 "context": browsing_context_id,
1744 },
1745 "awaitPromise": true,
1746 }),
1747 )
1748 .await?;
1749 let result_type = json_string(&result, "/result/type")?;
1750 if result_type != "success" {
1751 let details = result.pointer("/result/exceptionDetails").cloned().unwrap_or(Value::Null);
1752 return Err(format!("Firefox script.evaluate failed: {details}"));
1753 }
1754 let value = json_string(&result, "/result/result/value")?;
1755 serde_json::from_str::<Value>(&value)
1756 .map_err(|error| format!("failed to parse Firefox script result JSON: {error}"))
1757}
1758
1759async fn firefox_evaluate_void(
1760 bidi: &mut BidiConnection,
1761 browsing_context_id: &str,
1762 expression: &str,
1763) -> Result<(), String> {
1764 firefox_evaluate_json(
1765 bidi,
1766 browsing_context_id,
1767 &format!("JSON.stringify((() => {{ {expression}; return null; }})())"),
1768 )
1769 .await
1770 .map(|_| ())
1771}
1772
1773async fn firefox_evaluate_u32(
1774 bidi: &mut BidiConnection,
1775 browsing_context_id: &str,
1776 expression: &str,
1777) -> Result<u32, String> {
1778 let value = firefox_evaluate_json(
1779 bidi,
1780 browsing_context_id,
1781 &format!("JSON.stringify({expression})"),
1782 )
1783 .await?;
1784 if let Some(number) = value.as_u64() {
1785 return u32::try_from(number)
1786 .map_err(|_| format!("Firefox script result exceeds u32: {number}"));
1787 }
1788 if let Some(number) = value.as_i64() {
1789 return u32::try_from(number)
1790 .map_err(|_| format!("Firefox script result is out of range for u32: {number}"));
1791 }
1792 Err(format!("expected numeric Firefox script result, found {value}"))
1793}
1794
1795async fn firefox_evaluate_bool(
1796 bidi: &mut BidiConnection,
1797 browsing_context_id: &str,
1798 expression: &str,
1799) -> Result<bool, String> {
1800 let value = firefox_evaluate_json(
1801 bidi,
1802 browsing_context_id,
1803 &format!("JSON.stringify({expression})"),
1804 )
1805 .await?;
1806 value.as_bool().ok_or_else(|| format!("expected boolean Firefox script result, found {value}"))
1807}
1808
1809async fn firefox_get_text(
1810 connection_id: &str,
1811 browsing_context_id: &str,
1812 css_selector: &str,
1813 property: &str,
1814) -> Result<TextInfo, String> {
1815 firefox_require_selector(css_selector, "get_text")?;
1816 let mut sessions = firefox_session_guard(connection_id).await?;
1817 let bidi = &mut sessions
1818 .get_mut(connection_id)
1819 .expect("Firefox session guard validated connection id")
1820 .connection;
1821 let value = firefox_evaluate_json(
1822 bidi,
1823 browsing_context_id,
1824 &format!(
1825 "JSON.stringify((() => {{
1826 const selector = {};
1827 const property = {};
1828 const element = document.querySelector(selector);
1829 if (!element) {{
1830 throw new Error(`No element matches selector: ${{selector}}`);
1831 }}
1832 const value = element[property];
1833 return typeof value === 'string' ? value : '';
1834 }})())",
1835 json_string_literal(css_selector, "text selector")?,
1836 json_string_literal(property, "text property")?,
1837 ),
1838 )
1839 .await?;
1840
1841 Ok(TextInfo {
1842 css_selector: css_selector.to_string(),
1843 text: value
1844 .as_str()
1845 .map(str::to_string)
1846 .unwrap_or_default(),
1847 note: format!("resolved {property} for css selector {css_selector}"),
1848 })
1849}
1850
1851pub async fn boot() -> String {
1852 sleep(Duration::from_millis(15)).await;
1853 "web ready".to_string()
1854}
1855
1856#[cfg(unix)]
1857fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
1858 let status = Command::new("kill")
1859 .args(["-TERM", &process_id.to_string()])
1860 .status()
1861 .map_err(|error| format!("failed to send TERM to process {process_id}: {error}"))?;
1862
1863 if status.success() {
1864 Ok(())
1865 } else {
1866 Err(format!(
1867 "kill -TERM returned non-zero exit status for process {process_id}: {status}"
1868 ))
1869 }
1870}
1871
1872#[cfg(windows)]
1873fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
1874 let status = Command::new("taskkill")
1875 .args(["/PID", &process_id.to_string(), "/T", "/F"])
1876 .status()
1877 .map_err(|error| format!("failed to terminate process {process_id}: {error}"))?;
1878
1879 if status.success() {
1880 Ok(())
1881 } else {
1882 Err(format!(
1883 "taskkill returned non-zero exit status for process {process_id}: {status}"
1884 ))
1885 }
1886}
1887
1888#[cfg(not(any(unix, windows)))]
1889fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
1890 Err(format!(
1891 "browser process termination is not implemented for process {process_id} on this platform"
1892 ))
1893}
1894
1895type CdpSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
1896type BidiSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
1897
1898struct MapperConnectionInfo {
1899 package_version: String,
1900 mapper_target_id: String,
1901 mapper_session_id: String,
1902}
1903
1904struct CdpConnection {
1905 socket: CdpSocket,
1906 next_id: u64,
1907}
1908
1909impl CdpConnection {
1910 async fn connect(cdp_websocket_url: &str) -> Result<Self, String> {
1911 let (socket, _) = connect_async(cdp_websocket_url).await.map_err(|error| {
1912 format!("failed to connect to CDP websocket {cdp_websocket_url}: {error}")
1913 })?;
1914 Ok(Self { socket, next_id: 1 })
1915 }
1916
1917 async fn send_command(
1918 &mut self,
1919 method: &str,
1920 params: Value,
1921 session_id: Option<&str>,
1922 ) -> Result<Value, String> {
1923 let id = self.next_id;
1924 self.next_id += 1;
1925
1926 let mut payload = json!({
1927 "id": id,
1928 "method": method,
1929 "params": params,
1930 });
1931 if let Some(session_id) = session_id {
1932 payload["sessionId"] = Value::String(session_id.to_string());
1933 }
1934
1935 self.socket
1936 .send(Message::Text(payload.to_string().into()))
1937 .await
1938 .map_err(|error| format!("failed to send CDP command {method}: {error}"))?;
1939
1940 loop {
1941 let message = self.next_json_message().await?;
1942 if message.get("id").and_then(Value::as_u64) != Some(id) {
1943 continue;
1944 }
1945
1946 if let Some(error) = message.get("error") {
1947 return Err(format!("CDP command {method} failed: {error}"));
1948 }
1949
1950 return Ok(message.get("result").cloned().unwrap_or(Value::Null));
1951 }
1952 }
1953
1954 async fn evaluate_expression(
1955 &mut self,
1956 session_id: &str,
1957 expression: &str,
1958 await_promise: bool,
1959 ) -> Result<Value, String> {
1960 let result = self
1961 .send_command(
1962 "Runtime.evaluate",
1963 json!({
1964 "expression": expression,
1965 "awaitPromise": await_promise,
1966 "returnByValue": true,
1967 }),
1968 Some(session_id),
1969 )
1970 .await?;
1971 if let Some(exception_details) = result.get("exceptionDetails") {
1972 return Err(format!(
1973 "Runtime.evaluate failed with exception details: {exception_details}"
1974 ));
1975 }
1976 Ok(result)
1977 }
1978
1979 async fn attach_to_target(&mut self, target_id: &str) -> Result<String, String> {
1980 let attached = self
1981 .send_command(
1982 "Target.attachToTarget",
1983 json!({
1984 "targetId": target_id,
1985 "flatten": true,
1986 }),
1987 None,
1988 )
1989 .await?;
1990 required_string(&attached, "/sessionId")
1991 }
1992
1993 async fn prepare_page_target_session(&mut self, target_id: &str) -> Result<String, String> {
1994 let session_id = self.attach_to_target(target_id).await?;
1995 self.send_command("Runtime.enable", json!({}), Some(&session_id))
1996 .await?;
1997 self.send_command("Page.enable", json!({}), Some(&session_id))
1998 .await?;
1999 Ok(session_id)
2000 }
2001
2002 async fn detach_from_target(&mut self, session_id: &str) -> Result<(), String> {
2003 self.send_command(
2004 "Target.detachFromTarget",
2005 json!({
2006 "sessionId": session_id,
2007 }),
2008 None,
2009 )
2010 .await?;
2011 Ok(())
2012 }
2013
2014 async fn dispatch_mouse_click(
2015 &mut self,
2016 session_id: &str,
2017 x: f64,
2018 y: f64,
2019 ) -> Result<(), String> {
2020 self.dispatch_mouse_move(session_id, x, y).await?;
2021 self.send_command(
2022 "Input.dispatchMouseEvent",
2023 json!({
2024 "type": "mousePressed",
2025 "x": x,
2026 "y": y,
2027 "button": "left",
2028 "buttons": 1,
2029 "clickCount": 1,
2030 }),
2031 Some(session_id),
2032 )
2033 .await?;
2034 self.send_command(
2035 "Input.dispatchMouseEvent",
2036 json!({
2037 "type": "mouseReleased",
2038 "x": x,
2039 "y": y,
2040 "button": "left",
2041 "buttons": 0,
2042 "clickCount": 1,
2043 }),
2044 Some(session_id),
2045 )
2046 .await?;
2047 Ok(())
2048 }
2049
2050 async fn dispatch_mouse_move(
2051 &mut self,
2052 session_id: &str,
2053 x: f64,
2054 y: f64,
2055 ) -> Result<(), String> {
2056 self.send_command(
2057 "Input.dispatchMouseEvent",
2058 json!({
2059 "type": "mouseMoved",
2060 "x": x,
2061 "y": y,
2062 "button": "none",
2063 "buttons": 0,
2064 }),
2065 Some(session_id),
2066 )
2067 .await?;
2068 Ok(())
2069 }
2070
2071 async fn dispatch_key_press(
2072 &mut self,
2073 session_id: &str,
2074 key: &str,
2075 text: Option<&str>,
2076 ) -> Result<(), String> {
2077 self.send_command(
2078 "Input.dispatchKeyEvent",
2079 json!({
2080 "type": "keyDown",
2081 "key": key,
2082 "text": text.unwrap_or(""),
2083 }),
2084 Some(session_id),
2085 )
2086 .await?;
2087 self.send_command(
2088 "Input.dispatchKeyEvent",
2089 json!({
2090 "type": "keyUp",
2091 "key": key,
2092 "text": text.unwrap_or(""),
2093 }),
2094 Some(session_id),
2095 )
2096 .await?;
2097 Ok(())
2098 }
2099
2100 async fn ensure_chromium_bidi_mapper(
2101 &mut self,
2102 existing_mapper_target_id: Option<&str>,
2103 ) -> Result<MapperConnectionInfo, String> {
2104 let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
2105 Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
2106 (existing_mapper_target_id.to_string(), false)
2107 }
2108 _ => (self.create_bidi_mapper_target().await?, true),
2109 };
2110
2111 let attached = match self
2112 .send_command(
2113 "Target.attachToTarget",
2114 json!({
2115 "targetId": mapper_target_id,
2116 "flatten": true,
2117 }),
2118 None,
2119 )
2120 .await
2121 {
2122 Ok(attached) => attached,
2123 Err(error)
2124 if !created_target
2125 && error.contains("Target.attachToTarget failed")
2126 && error.contains("No target with given id found") =>
2127 {
2128 mapper_target_id = self.create_bidi_mapper_target().await?;
2129 created_target = true;
2130 self.send_command(
2131 "Target.attachToTarget",
2132 json!({
2133 "targetId": mapper_target_id,
2134 "flatten": true,
2135 }),
2136 None,
2137 )
2138 .await?
2139 }
2140 Err(error) => return Err(error),
2141 };
2142 let mapper_session_id = required_string(&attached, "/sessionId")?;
2143
2144 self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
2145 .await?;
2146 self.send_command(
2147 "Target.exposeDevToolsProtocol",
2148 json!({
2149 "bindingName": "cdp",
2150 "targetId": mapper_target_id,
2151 "inheritPermissions": true,
2152 }),
2153 None,
2154 )
2155 .await?;
2156 self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
2157 .await?;
2158
2159 if created_target
2160 || !self
2161 .mapper_runtime_is_initialized(&mapper_session_id)
2162 .await?
2163 {
2164 self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
2165 .await?;
2166 self.evaluate_expression(
2167 &mapper_session_id,
2168 &format!(
2169 "window.runMapperInstance('{}')",
2170 js_single_quote(&mapper_target_id)
2171 ),
2172 true,
2173 )
2174 .await?;
2175 self.send_bidi_command(
2176 &mapper_session_id,
2177 &json!({
2178 "id": 1,
2179 "method": "session.new",
2180 "params": {
2181 "capabilities": {}
2182 }
2183 }),
2184 )
2185 .await?;
2186 }
2187
2188 Ok(MapperConnectionInfo {
2189 package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
2190 mapper_target_id,
2191 mapper_session_id,
2192 })
2193 }
2194
2195 async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
2196 let created = self
2197 .send_command(
2198 "Target.createTarget",
2199 json!({
2200 "url": "about:blank#MAPPER_TARGET",
2201 "hidden": true,
2202 "background": true,
2203 }),
2204 None,
2205 )
2206 .await?;
2207 required_string(&created, "/targetId")
2208 }
2209
2210 async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
2211 match self
2212 .send_command(
2213 "Runtime.addBinding",
2214 json!({
2215 "name": name,
2216 }),
2217 Some(session_id),
2218 )
2219 .await
2220 {
2221 Ok(_) => Ok(()),
2222 Err(error)
2223 if error.contains("already exists") || error.contains("Cannot add binding") =>
2224 {
2225 Ok(())
2226 }
2227 Err(error) => Err(error),
2228 }
2229 }
2230
2231 async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
2232 let result = self
2233 .evaluate_expression(
2234 session_id,
2235 "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
2236 true,
2237 )
2238 .await?;
2239 Ok(result
2240 .pointer("/result/value")
2241 .and_then(Value::as_bool)
2242 .unwrap_or(false))
2243 }
2244
2245 async fn send_bidi_command(
2246 &mut self,
2247 mapper_session_id: &str,
2248 command: &Value,
2249 ) -> Result<Value, String> {
2250 let command_id = command
2251 .get("id")
2252 .and_then(Value::as_u64)
2253 .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
2254 let eval_id = self.next_id;
2255 self.next_id += 1;
2256 let command_json = command.to_string();
2257 let expression = format!(
2258 "onBidiMessage({})",
2259 serde_json::to_string(&command_json)
2260 .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
2261 );
2262
2263 let payload = json!({
2264 "id": eval_id,
2265 "method": "Runtime.evaluate",
2266 "params": {
2267 "expression": expression,
2268 "awaitPromise": true,
2269 },
2270 "sessionId": mapper_session_id,
2271 });
2272 self.socket
2273 .send(Message::Text(payload.to_string().into()))
2274 .await
2275 .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
2276
2277 loop {
2278 let message = self.next_json_message().await?;
2279
2280 if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
2281 if let Some(error) = message.get("error") {
2282 return Err(format!("mapper Runtime.evaluate failed: {error}"));
2283 }
2284 if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
2285 return Err(format!(
2286 "mapper Runtime.evaluate raised exception details: {exception_details}"
2287 ));
2288 }
2289 continue;
2290 }
2291
2292 if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
2293 continue;
2294 }
2295
2296 if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
2297 continue;
2298 }
2299
2300 let params = message
2301 .get("params")
2302 .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
2303 if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
2304 continue;
2305 }
2306
2307 let payload = params
2308 .get("payload")
2309 .and_then(Value::as_str)
2310 .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
2311 let response = serde_json::from_str::<Value>(payload)
2312 .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
2313 if response.get("id").and_then(Value::as_u64) != Some(command_id) {
2314 continue;
2315 }
2316
2317 match response.get("type").and_then(Value::as_str) {
2318 Some("success") => return Ok(response),
2319 Some("error") => {
2320 let message = response
2321 .get("message")
2322 .and_then(Value::as_str)
2323 .unwrap_or("unknown BiDi mapper error");
2324 let error = response
2325 .get("error")
2326 .and_then(Value::as_str)
2327 .unwrap_or("unknown error");
2328 return Err(format!("BiDi mapper returned {error}: {message}"));
2329 }
2330 Some(other) => {
2331 return Err(format!("unexpected BiDi response type {other}: {response}"));
2332 }
2333 None => {
2334 return Err(format!("BiDi mapper response is missing type: {response}"));
2335 }
2336 }
2337 }
2338 }
2339
2340 async fn resolve_bidi_context_id(
2341 &mut self,
2342 mapper_session_id: &str,
2343 existing_context_id: Option<&str>,
2344 current_url: Option<&str>,
2345 ) -> Result<String, String> {
2346 if let Some(existing_context_id) = existing_context_id {
2347 if !existing_context_id.trim().is_empty() {
2348 let probe = json!({
2349 "id": 1,
2350 "method": "browsingContext.getTree",
2351 "params": {
2352 "root": existing_context_id,
2353 "maxDepth": 0,
2354 }
2355 });
2356 match self.send_bidi_command(mapper_session_id, &probe).await {
2357 Ok(_) => return Ok(existing_context_id.to_string()),
2358 Err(error)
2359 if error.contains("no such frame")
2360 || error.contains("Context ")
2361 || error.contains("invalid argument") => {}
2362 Err(error) => return Err(error),
2363 }
2364 }
2365 }
2366
2367 let target_url = current_url
2368 .map(str::trim)
2369 .filter(|value| !value.is_empty())
2370 .map(str::to_string);
2371
2372 for attempt in 0..20 {
2373 let tree = self
2374 .send_bidi_command(
2375 mapper_session_id,
2376 &json!({
2377 "id": 2,
2378 "method": "browsingContext.getTree",
2379 "params": {
2380 "maxDepth": 0,
2381 }
2382 }),
2383 )
2384 .await?;
2385 let contexts = tree
2386 .pointer("/result/contexts")
2387 .and_then(Value::as_array)
2388 .ok_or_else(|| {
2389 format!("BiDi browsingContext.getTree returned no contexts: {tree}")
2390 })?;
2391
2392 if let Some(target_url) = target_url.as_deref() {
2393 if let Some(context_id) = contexts.iter().find_map(|context| {
2394 let url = context.get("url").and_then(Value::as_str)?;
2395 if url == target_url {
2396 context
2397 .get("context")
2398 .and_then(Value::as_str)
2399 .map(str::to_string)
2400 } else {
2401 None
2402 }
2403 }) {
2404 return Ok(context_id);
2405 }
2406 }
2407
2408 if let Some(context_id) = contexts
2409 .first()
2410 .and_then(|context| context.get("context"))
2411 .and_then(Value::as_str)
2412 .filter(|value| !value.trim().is_empty())
2413 .map(str::to_string)
2414 {
2415 return Ok(context_id);
2416 }
2417
2418 if attempt < 19 {
2419 sleep(Duration::from_millis(150)).await;
2420 } else {
2421 return Err(format!(
2422 "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
2423 ));
2424 }
2425 }
2426
2427 Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
2428 }
2429
2430 async fn navigate_and_wait_for_load(
2431 &mut self,
2432 session_id: &str,
2433 url: &str,
2434 ) -> Result<(), String> {
2435 let id = self.next_id;
2436 self.next_id += 1;
2437
2438 let payload = json!({
2439 "id": id,
2440 "method": "Page.navigate",
2441 "params": {
2442 "url": url,
2443 },
2444 "sessionId": session_id,
2445 });
2446 self.socket
2447 .send(Message::Text(payload.to_string().into()))
2448 .await
2449 .map_err(|error| format!("failed to send CDP command Page.navigate: {error}"))?;
2450
2451 let mut navigate_confirmed = false;
2452 let mut load_seen = false;
2453
2454 loop {
2455 let message = self.next_json_message().await?;
2456
2457 if message.get("id").and_then(Value::as_u64) == Some(id) {
2458 if let Some(error) = message.get("error") {
2459 return Err(format!("CDP command Page.navigate failed: {error}"));
2460 }
2461 navigate_confirmed = true;
2462 } else if message.get("sessionId").and_then(Value::as_str) == Some(session_id)
2463 && message.get("method").and_then(Value::as_str) == Some("Page.loadEventFired")
2464 {
2465 load_seen = true;
2466 }
2467
2468 if navigate_confirmed && load_seen {
2469 return Ok(());
2470 }
2471 }
2472 }
2473
2474 async fn next_json_message(&mut self) -> Result<Value, String> {
2475 loop {
2476 let message = self
2477 .socket
2478 .next()
2479 .await
2480 .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
2481 .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
2482
2483 match message {
2484 Message::Text(text) => {
2485 let value = serde_json::from_str::<Value>(&text)
2486 .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
2487 return Ok(value);
2488 }
2489 Message::Binary(bytes) => {
2490 let value = serde_json::from_slice::<Value>(&bytes)
2491 .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
2492 return Ok(value);
2493 }
2494 Message::Ping(payload) => {
2495 self.socket
2496 .send(Message::Pong(payload))
2497 .await
2498 .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
2499 }
2500 Message::Pong(_) => {}
2501 Message::Frame(_) => {}
2502 Message::Close(frame) => {
2503 return Err(format!("CDP websocket closed: {frame:?}"));
2504 }
2505 }
2506 }
2507 }
2508}
2509
2510struct BidiConnection {
2511 socket: BidiSocket,
2512 next_id: u64,
2513}
2514
2515impl BidiConnection {
2516 async fn connect(bidi_websocket_url: &str) -> Result<Self, String> {
2517 let (socket, _) = connect_async(bidi_websocket_url).await.map_err(|error| {
2518 format!("failed to connect to WebDriver BiDi websocket {bidi_websocket_url}: {error}")
2519 })?;
2520 Ok(Self { socket, next_id: 1 })
2521 }
2522
2523 async fn send_command(&mut self, method: &str, params: Value) -> Result<Value, String> {
2524 let id = self.next_id;
2525 self.next_id += 1;
2526 let payload = json!({
2527 "id": id,
2528 "method": method,
2529 "params": params,
2530 });
2531 self.socket
2532 .send(Message::Text(payload.to_string().into()))
2533 .await
2534 .map_err(|error| format!("failed to send WebDriver BiDi command {method}: {error}"))?;
2535
2536 loop {
2537 let message = self.next_json_message().await?;
2538 if message.get("id").and_then(Value::as_u64) != Some(id) {
2539 continue;
2540 }
2541
2542 if let Some(error) = message.get("error").and_then(Value::as_str) {
2543 let message_text = message
2544 .get("message")
2545 .and_then(Value::as_str)
2546 .unwrap_or("unknown WebDriver BiDi error");
2547 return Err(format!("WebDriver BiDi command {method} returned {error}: {message_text}"));
2548 }
2549
2550 return Ok(message);
2551 }
2552 }
2553
2554 async fn wait_for_top_level_context(&mut self) -> Result<String, String> {
2555 for _ in 0..100 {
2556 let tree = self
2557 .send_command(
2558 "browsingContext.getTree",
2559 json!({
2560 "maxDepth": 0,
2561 }),
2562 )
2563 .await?;
2564 if let Some(context_id) = tree
2565 .pointer("/result/contexts")
2566 .and_then(Value::as_array)
2567 .and_then(|contexts| contexts.first())
2568 .and_then(|context| context.get("context"))
2569 .and_then(Value::as_str)
2570 .filter(|value| !value.trim().is_empty())
2571 {
2572 return Ok(context_id.to_string());
2573 }
2574 sleep(Duration::from_millis(50)).await;
2575 }
2576
2577 Err("timed out waiting for an initial Firefox browsing context".to_string())
2578 }
2579
2580 async fn next_json_message(&mut self) -> Result<Value, String> {
2581 loop {
2582 let message = self
2583 .socket
2584 .next()
2585 .await
2586 .ok_or_else(|| "WebDriver BiDi websocket closed unexpectedly".to_string())?
2587 .map_err(|error| format!("failed to read WebDriver BiDi websocket message: {error}"))?;
2588
2589 match message {
2590 Message::Text(text) => {
2591 let value = serde_json::from_str::<Value>(&text).map_err(|error| {
2592 format!("failed to parse WebDriver BiDi JSON message: {error}")
2593 })?;
2594 return Ok(value);
2595 }
2596 Message::Binary(bytes) => {
2597 let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
2598 format!("failed to parse WebDriver BiDi JSON message: {error}")
2599 })?;
2600 return Ok(value);
2601 }
2602 Message::Ping(payload) => {
2603 self.socket
2604 .send(Message::Pong(payload))
2605 .await
2606 .map_err(|error| format!("failed to reply to WebDriver BiDi ping: {error}"))?;
2607 }
2608 Message::Pong(_) => {}
2609 Message::Frame(_) => {}
2610 Message::Close(frame) => {
2611 return Err(format!("WebDriver BiDi websocket closed: {frame:?}"));
2612 }
2613 }
2614 }
2615 }
2616}
2617
2618fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
2619 value
2620 .pointer(pointer)
2621 .and_then(Value::as_str)
2622 .map(str::to_string)
2623 .filter(|value| !value.is_empty())
2624 .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
2625}
2626
2627fn js_single_quote(value: &str) -> String {
2628 value.replace('\\', "\\\\").replace('\'', "\\'")
2629}
2630
2631fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
2632 let raw = value
2633 .pointer(pointer)
2634 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
2635
2636 if let Some(number) = raw.as_u64() {
2637 return u32::try_from(number)
2638 .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
2639 }
2640
2641 if let Some(number) = raw.as_i64() {
2642 return u32::try_from(number).map_err(|_| {
2643 format!("numeric JSON value at {pointer} is out of range for u32: {number}")
2644 });
2645 }
2646
2647 Err(format!(
2648 "expected numeric JSON value at {pointer}, found {raw}"
2649 ))
2650}
2651
2652fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
2653 let raw = value
2654 .pointer(pointer)
2655 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
2656
2657 raw.as_f64()
2658 .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
2659}
2660
2661fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
2662 value
2663 .pointer(pointer)
2664 .and_then(Value::as_str)
2665 .map(str::to_string)
2666 .ok_or_else(|| format!("expected string JSON value at {pointer}"))
2667}
2668
2669fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
2670 serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
2671}
2672
2673fn require_non_empty_selector(command_name: &str, css_selector: &str) -> Result<(), String> {
2674 if css_selector.trim().is_empty() {
2675 Err(format!(
2676 "{command_name} command requires a non-empty css_selector"
2677 ))
2678 } else {
2679 Ok(())
2680 }
2681}
2682
2683fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
2684where
2685 F: Future<Output = Result<T, String>>,
2686{
2687 let runtime = plugin_runtime()?;
2688 runtime.block_on(future)
2689}
2690
2691fn plugin_runtime() -> Result<&'static tokio::runtime::Runtime, String> {
2692 if let Some(runtime) = PLUGIN_RUNTIME.get() {
2693 return Ok(runtime);
2694 }
2695
2696 let runtime = tokio::runtime::Builder::new_multi_thread()
2697 .enable_all()
2698 .worker_threads(2)
2699 .thread_name("allwright-web-plugin")
2700 .build()
2701 .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
2702 let _ = PLUGIN_RUNTIME.set(runtime);
2703 PLUGIN_RUNTIME
2704 .get()
2705 .ok_or_else(|| "failed to initialize plugin runtime".to_string())
2706}
2707
2708fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
2709 let envelope = match result {
2710 Ok(result) => PluginEnvelope {
2711 ok: true,
2712 result: Some(result),
2713 error: None,
2714 },
2715 Err(error) => PluginEnvelope {
2716 ok: false,
2717 result: None,
2718 error: Some(error),
2719 },
2720 };
2721
2722 let json = match serde_json::to_string(&envelope) {
2723 Ok(json) => json,
2724 Err(error) => {
2725 let fallback = PluginEnvelope {
2726 ok: false,
2727 result: None,
2728 error: Some(format!("failed to serialize plugin response: {error}")),
2729 };
2730 serde_json::to_string(&fallback).unwrap_or_else(|_| {
2731 "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
2732 .to_string()
2733 })
2734 }
2735 };
2736
2737 CString::new(json).unwrap().into_raw()
2738}
2739
2740fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
2741 match command {
2742 PluginCommand::LaunchBrowser {
2743 browser_kind,
2744 browser_binary,
2745 } => launch_browser(browser_kind, browser_binary.as_deref()).map(PluginResult::LaunchBrowser),
2746 PluginCommand::OpenPage { browser_session } => block_on_plugin_future(async move {
2747 open_page(&browser_session).await.map(PluginResult::OpenPage)
2748 }),
2749 PluginCommand::ClosePage {
2750 browser_session,
2751 page_session,
2752 } => block_on_plugin_future(async move {
2753 close_page(&browser_session, &page_session).await?;
2754 Ok(PluginResult::ClosePage)
2755 }),
2756 PluginCommand::NavigatePage {
2757 browser_session,
2758 page_session,
2759 url,
2760 } => block_on_plugin_future(async move {
2761 navigate_page(&browser_session, &page_session, &url)
2762 .await
2763 .map(PluginResult::NavigatePage)
2764 }),
2765 PluginCommand::ClickElement {
2766 browser_session,
2767 page_session,
2768 css_selector,
2769 } => block_on_plugin_future(async move {
2770 click_element(&browser_session, &page_session, &css_selector)
2771 .await
2772 .map(PluginResult::ClickElement)
2773 }),
2774 PluginCommand::CountElements {
2775 browser_session,
2776 page_session,
2777 css_selector,
2778 } => block_on_plugin_future(async move {
2779 count_elements(&browser_session, &page_session, &css_selector)
2780 .await
2781 .map(PluginResult::CountElements)
2782 }),
2783 PluginCommand::HighlightElements {
2784 browser_session,
2785 page_session,
2786 css_selector,
2787 duration_ms,
2788 } => block_on_plugin_future(async move {
2789 highlight_elements(&browser_session, &page_session, &css_selector, duration_ms)
2790 .await
2791 .map(PluginResult::HighlightElements)
2792 }),
2793 PluginCommand::FocusElement {
2794 browser_session,
2795 page_session,
2796 css_selector,
2797 } => block_on_plugin_future(async move {
2798 focus_element(&browser_session, &page_session, &css_selector)
2799 .await
2800 .map(PluginResult::FocusElement)
2801 }),
2802 PluginCommand::FillElement {
2803 browser_session,
2804 page_session,
2805 css_selector,
2806 value,
2807 } => block_on_plugin_future(async move {
2808 fill_element(&browser_session, &page_session, &css_selector, &value)
2809 .await
2810 .map(PluginResult::FillElement)
2811 }),
2812 PluginCommand::HoverElement {
2813 browser_session,
2814 page_session,
2815 css_selector,
2816 } => block_on_plugin_future(async move {
2817 hover_element(&browser_session, &page_session, &css_selector)
2818 .await
2819 .map(PluginResult::HoverElement)
2820 }),
2821 PluginCommand::PressKey {
2822 browser_session,
2823 page_session,
2824 css_selector,
2825 key,
2826 text,
2827 } => block_on_plugin_future(async move {
2828 press_key(
2829 &browser_session,
2830 &page_session,
2831 &css_selector,
2832 &key,
2833 text.as_deref(),
2834 )
2835 .await
2836 .map(PluginResult::PressKey)
2837 }),
2838 PluginCommand::GetTextContent {
2839 browser_session,
2840 page_session,
2841 css_selector,
2842 } => block_on_plugin_future(async move {
2843 get_text_content(&browser_session, &page_session, &css_selector)
2844 .await
2845 .map(PluginResult::GetTextContent)
2846 }),
2847 PluginCommand::GetInnerText {
2848 browser_session,
2849 page_session,
2850 css_selector,
2851 } => block_on_plugin_future(async move {
2852 get_inner_text(&browser_session, &page_session, &css_selector)
2853 .await
2854 .map(PluginResult::GetInnerText)
2855 }),
2856 PluginCommand::WaitForSelector {
2857 browser_session,
2858 page_session,
2859 css_selector,
2860 visible,
2861 } => block_on_plugin_future(async move {
2862 wait_for_selector(&browser_session, &page_session, &css_selector, visible)
2863 .await
2864 .map(PluginResult::WaitForSelector)
2865 }),
2866 PluginCommand::OpenChromeWindow { chrome_binary } => {
2867 open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
2868 }
2869 PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
2870 block_on_plugin_future(async move {
2871 let result = discover_initial_tab(&cdp_websocket_url).await?;
2872 Ok(PluginResult::DiscoverInitialTab(result))
2873 })
2874 }
2875 PluginCommand::OpenChromeTab { cdp_websocket_url } => {
2876 block_on_plugin_future(async move {
2877 let result = open_chrome_tab(&cdp_websocket_url).await?;
2878 Ok(PluginResult::OpenChromeTab(result))
2879 })
2880 }
2881 PluginCommand::CloseBrowserProcess { process_id } => {
2882 close_browser_process(process_id)?;
2883 Ok(PluginResult::CloseBrowserProcess)
2884 }
2885 PluginCommand::CloseChromeTab {
2886 cdp_websocket_url,
2887 target_id,
2888 } => block_on_plugin_future(async move {
2889 close_chrome_tab(&cdp_websocket_url, &target_id).await?;
2890 Ok(PluginResult::CloseChromeTab)
2891 }),
2892 PluginCommand::NavigateChromeTab {
2893 cdp_websocket_url,
2894 target_id,
2895 url,
2896 } => block_on_plugin_future(async move {
2897 let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
2898 Ok(PluginResult::NavigateChromeTab(result))
2899 }),
2900 PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
2901 block_on_plugin_future(async move {
2902 let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
2903 Ok(PluginResult::InjectChromiumBidiMapper(result))
2904 })
2905 }
2906 PluginCommand::ResolveBidiContextForTab {
2907 cdp_websocket_url,
2908 mapper_target_id,
2909 browsing_context_id,
2910 url,
2911 } => block_on_plugin_future(async move {
2912 let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
2913 &cdp_websocket_url,
2914 mapper_target_id.as_deref(),
2915 browsing_context_id.as_deref(),
2916 url.as_deref(),
2917 )
2918 .await?;
2919 Ok(PluginResult::ResolveBidiContextForTab {
2920 browsing_context_id: resolved_browsing_context_id,
2921 mapper,
2922 })
2923 }),
2924 PluginCommand::ClickElementViaCdp {
2925 cdp_websocket_url,
2926 target_id,
2927 css_selector,
2928 } => block_on_plugin_future(async move {
2929 let result = click_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2930 Ok(PluginResult::ClickElementViaCdp(result))
2931 }),
2932 PluginCommand::CountElementsViaCdp {
2933 cdp_websocket_url,
2934 target_id,
2935 css_selector,
2936 } => block_on_plugin_future(async move {
2937 let result = count_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2938 Ok(PluginResult::CountElementsViaCdp(result))
2939 }),
2940 PluginCommand::HighlightElementsViaCdp {
2941 cdp_websocket_url,
2942 target_id,
2943 css_selector,
2944 duration_ms,
2945 } => block_on_plugin_future(async move {
2946 let duration_ms = u32::try_from(duration_ms)
2947 .map_err(|_| format!("highlight duration {duration_ms} exceeds u32"))?;
2948 let result =
2949 highlight_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector, duration_ms)
2950 .await?;
2951 Ok(PluginResult::HighlightElementsViaCdp(result))
2952 }),
2953 PluginCommand::FocusElementViaCdp {
2954 cdp_websocket_url,
2955 target_id,
2956 css_selector,
2957 } => block_on_plugin_future(async move {
2958 let result = focus_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2959 Ok(PluginResult::FocusElementViaCdp(result))
2960 }),
2961 PluginCommand::FillElementViaCdp {
2962 cdp_websocket_url,
2963 target_id,
2964 css_selector,
2965 value,
2966 } => block_on_plugin_future(async move {
2967 let result =
2968 fill_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector, &value).await?;
2969 Ok(PluginResult::FillElementViaCdp(result))
2970 }),
2971 PluginCommand::HoverElementViaCdp {
2972 cdp_websocket_url,
2973 target_id,
2974 css_selector,
2975 } => block_on_plugin_future(async move {
2976 let result = hover_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2977 Ok(PluginResult::HoverElementViaCdp(result))
2978 }),
2979 PluginCommand::PressKeyViaCdp {
2980 cdp_websocket_url,
2981 target_id,
2982 css_selector,
2983 key,
2984 text,
2985 } => block_on_plugin_future(async move {
2986 let result = press_key_via_cdp(
2987 &cdp_websocket_url,
2988 &target_id,
2989 &css_selector,
2990 &key,
2991 text.as_deref(),
2992 )
2993 .await?;
2994 Ok(PluginResult::PressKeyViaCdp(result))
2995 }),
2996 PluginCommand::GetTextContentViaCdp {
2997 cdp_websocket_url,
2998 target_id,
2999 css_selector,
3000 } => block_on_plugin_future(async move {
3001 let result =
3002 get_text_content_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3003 Ok(PluginResult::GetTextContentViaCdp(result))
3004 }),
3005 PluginCommand::GetInnerTextViaCdp {
3006 cdp_websocket_url,
3007 target_id,
3008 css_selector,
3009 } => block_on_plugin_future(async move {
3010 let result = get_inner_text_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3011 Ok(PluginResult::GetInnerTextViaCdp(result))
3012 }),
3013 PluginCommand::WaitForSelectorViaCdp {
3014 cdp_websocket_url,
3015 target_id,
3016 css_selector,
3017 visible,
3018 } => block_on_plugin_future(async move {
3019 let result =
3020 wait_for_selector_via_cdp(&cdp_websocket_url, &target_id, &css_selector, visible)
3021 .await?;
3022 Ok(PluginResult::WaitForSelectorViaCdp(result))
3023 }),
3024 }
3025}
3026
3027#[unsafe(no_mangle)]
3028pub extern "C" fn allwright_plugin_api_version() -> u32 {
3029 ALLWRIGHT_PLUGIN_API_VERSION
3030}
3031
3032#[unsafe(no_mangle)]
3033pub extern "C" fn allwright_plugin_id() -> *const c_char {
3034 c"web".as_ptr()
3035}
3036
3037#[unsafe(no_mangle)]
3038pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
3039 if request_json.is_null() {
3040 return plugin_response(Err("plugin request pointer is null".to_string()));
3041 }
3042
3043 let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
3044 Ok(request) => request,
3045 Err(error) => {
3046 return plugin_response(Err(format!(
3047 "plugin request is not valid UTF-8: {error}"
3048 )));
3049 }
3050 };
3051
3052 let command: PluginCommand = match serde_json::from_str(request) {
3053 Ok(command) => command,
3054 Err(error) => {
3055 return plugin_response(Err(format!(
3056 "failed to parse plugin request JSON: {error}"
3057 )));
3058 }
3059 };
3060
3061 plugin_response(handle_plugin_command(command))
3062}
3063
3064#[unsafe(no_mangle)]
3065pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
3066 if !value.is_null() {
3067 unsafe {
3068 let _ = CString::from_raw(value);
3069 }
3070 }
3071}