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 std::sync::OnceLock;
21use tokio::net::TcpStream;
22use tokio::sync::{Mutex, MutexGuard};
23use tokio::time::{Duration, sleep};
24use tokio_tungstenite::MaybeTlsStream;
25use tokio_tungstenite::WebSocketStream;
26use tokio_tungstenite::connect_async;
27use tokio_tungstenite::tungstenite::Message;
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(
132 browser_kind: BrowserKind,
133 browser_binary: Option<&str>,
134) -> Result<BrowserLaunchInfo, String> {
135 match browser_kind {
136 BrowserKind::Chromium => {
137 let launch = open_chrome_window(browser_binary)?;
138 let initial_page = block_on_plugin_future(async {
139 discover_initial_tab(&launch.cdp_websocket_url).await
140 })?;
141 Ok(BrowserLaunchInfo {
142 browser_kind,
143 browser: launch.browser,
144 note: launch.note,
145 user_data_dir: launch.user_data_dir,
146 process_id: launch.process_id,
147 browser_session: BrowserSessionHandle::Chromium {
148 cdp_websocket_url: launch.cdp_websocket_url,
149 },
150 initial_page: PageInfo {
151 note: initial_page.note,
152 page_session: PageSessionHandle::Chromium {
153 target_id: initial_page.target_id,
154 browsing_context_id: None,
155 },
156 },
157 })
158 }
159 BrowserKind::Firefox => launch_firefox_with_bidi(browser_binary),
160 }
161}
162
163pub async fn open_page(browser_session: &BrowserSessionHandle) -> Result<PageInfo, String> {
164 match browser_session {
165 BrowserSessionHandle::Chromium { cdp_websocket_url } => {
166 let tab = open_chrome_tab(cdp_websocket_url).await?;
167 Ok(PageInfo {
168 note: tab.note,
169 page_session: PageSessionHandle::Chromium {
170 target_id: tab.target_id,
171 browsing_context_id: None,
172 },
173 })
174 }
175 BrowserSessionHandle::Firefox { connection_id, .. } => {
176 let mut sessions = firefox_session_guard(connection_id).await?;
177 let bidi = &mut sessions
178 .get_mut(connection_id)
179 .expect("Firefox session guard validated connection id")
180 .connection;
181 let created = bidi
182 .send_command(
183 "browsingContext.create",
184 json!({
185 "type": "tab",
186 "background": false,
187 }),
188 )
189 .await?;
190 Ok(PageInfo {
191 note: "opened Firefox tab via native WebDriver BiDi".to_string(),
192 page_session: PageSessionHandle::Firefox {
193 browsing_context_id: required_string(&created, "/result/context")?,
194 },
195 })
196 }
197 }
198}
199
200pub async fn close_page(
201 browser_session: &BrowserSessionHandle,
202 page_session: &PageSessionHandle,
203) -> Result<(), String> {
204 match (browser_session, page_session) {
205 (
206 BrowserSessionHandle::Chromium { cdp_websocket_url },
207 PageSessionHandle::Chromium { target_id, .. },
208 ) => close_chrome_tab(cdp_websocket_url, target_id).await,
209 (
210 BrowserSessionHandle::Firefox { connection_id, .. },
211 PageSessionHandle::Firefox {
212 browsing_context_id,
213 },
214 ) => {
215 let mut sessions = firefox_session_guard(connection_id).await?;
216 let bidi = &mut sessions
217 .get_mut(connection_id)
218 .expect("Firefox session guard validated connection id")
219 .connection;
220 bidi.send_command(
221 "browsingContext.close",
222 json!({
223 "context": browsing_context_id,
224 }),
225 )
226 .await?;
227 Ok(())
228 }
229 _ => Err("browser/page backend mismatch while closing page".to_string()),
230 }
231}
232
233pub async fn close_chrome_tab(cdp_websocket_url: &str, target_id: &str) -> Result<(), String> {
234 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
235 let response = cdp
236 .send_command(
237 "Target.closeTarget",
238 json!({
239 "targetId": target_id,
240 }),
241 None,
242 )
243 .await?;
244 let success = response
245 .get("success")
246 .and_then(Value::as_bool)
247 .unwrap_or(false);
248 if success {
249 Ok(())
250 } else {
251 Err(format!(
252 "CDP Target.closeTarget reported unsuccessful close for target {target_id}"
253 ))
254 }
255}
256
257pub async fn navigate_chrome_tab(
258 cdp_websocket_url: &str,
259 target_id: &str,
260 url: &str,
261) -> Result<TabNavigationInfo, String> {
262 if url.trim().is_empty() {
263 return Err("navigate command requires a non-empty url".to_string());
264 }
265
266 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
267 let attach = cdp
268 .send_command(
269 "Target.attachToTarget",
270 json!({
271 "targetId": target_id,
272 "flatten": true,
273 }),
274 None,
275 )
276 .await?;
277 let session_id = required_string(&attach, "/sessionId")?;
278
279 cdp.send_command("Page.enable", json!({}), Some(&session_id))
280 .await?;
281 cdp.navigate_and_wait_for_load(&session_id, url).await?;
282 let frame_tree = cdp
283 .send_command("Page.getFrameTree", json!({}), Some(&session_id))
284 .await?;
285 let browsing_context_id = required_string(&frame_tree, "/frameTree/frame/id")?;
286 cdp.send_command(
287 "Target.detachFromTarget",
288 json!({
289 "sessionId": session_id,
290 }),
291 None,
292 )
293 .await?;
294
295 Ok(TabNavigationInfo {
296 url: url.to_string(),
297 note: "navigated Chrome tab via CDP and observed Page.loadEventFired".to_string(),
298 page_session: PageSessionHandle::Chromium {
299 target_id: target_id.to_string(),
300 browsing_context_id: Some(browsing_context_id),
301 },
302 automation: AutomationSessionInfo {
303 bidi_session_id: String::new(),
304 note: String::new(),
305 mapper_target_id: None,
306 mapper_session_id: None,
307 package_version: None,
308 },
309 })
310}
311
312pub async fn navigate_page(
313 browser_session: &BrowserSessionHandle,
314 page_session: &PageSessionHandle,
315 url: &str,
316) -> Result<TabNavigationInfo, String> {
317 match (browser_session, page_session) {
318 (
319 BrowserSessionHandle::Chromium { cdp_websocket_url },
320 PageSessionHandle::Chromium {
321 target_id,
322 browsing_context_id,
323 },
324 ) => {
325 let navigation = navigate_chrome_tab(cdp_websocket_url, target_id, url).await?;
326 let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
327 cdp_websocket_url,
328 None,
329 browsing_context_id.as_deref(),
330 Some(&navigation.url),
331 )
332 .await?;
333 Ok(TabNavigationInfo {
334 url: navigation.url,
335 note: navigation.note,
336 page_session: PageSessionHandle::Chromium {
337 target_id: target_id.to_string(),
338 browsing_context_id: Some(resolved_browsing_context_id),
339 },
340 automation: AutomationSessionInfo {
341 bidi_session_id: format!("chromium-bidi:{target_id}"),
342 note: format!(
343 "chromium-bidi mapper ready from pinned chromium-bidi@{} after navigation",
344 mapper.package_version
345 ),
346 mapper_target_id: Some(mapper.mapper_target_id),
347 mapper_session_id: Some(mapper.mapper_session_id),
348 package_version: Some(mapper.package_version),
349 },
350 })
351 }
352 (
353 BrowserSessionHandle::Firefox {
354 connection_id,
355 bidi_session_id,
356 },
357 PageSessionHandle::Firefox {
358 browsing_context_id,
359 },
360 ) => {
361 let mut sessions = firefox_session_guard(connection_id).await?;
362 let bidi = &mut sessions
363 .get_mut(connection_id)
364 .expect("Firefox session guard validated connection id")
365 .connection;
366 let result = bidi
367 .send_command(
368 "browsingContext.navigate",
369 json!({
370 "context": browsing_context_id,
371 "url": url,
372 "wait": "complete",
373 }),
374 )
375 .await?;
376 Ok(TabNavigationInfo {
377 url: json_string(&result, "/result/url")?,
378 note: "navigated Firefox tab via native WebDriver BiDi".to_string(),
379 page_session: page_session.clone(),
380 automation: AutomationSessionInfo {
381 bidi_session_id: bidi_session_id.clone(),
382 note: "connected to native Firefox WebDriver BiDi session".to_string(),
383 mapper_target_id: None,
384 mapper_session_id: None,
385 package_version: None,
386 },
387 })
388 }
389 _ => Err("browser/page backend mismatch while navigating".to_string()),
390 }
391}
392
393pub async fn inject_chromium_bidi_mapper(
394 cdp_websocket_url: &str,
395) -> Result<ChromiumBidiMapperInfo, String> {
396 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
397 let mapper_target = cdp
398 .send_command(
399 "Target.createTarget",
400 json!({
401 "url": "about:blank#MAPPER_TARGET",
402 "hidden": true,
403 "background": true,
404 }),
405 None,
406 )
407 .await?;
408 let mapper_target_id = required_string(&mapper_target, "/targetId")?;
409
410 let attached = cdp
411 .send_command(
412 "Target.attachToTarget",
413 json!({
414 "targetId": mapper_target_id,
415 "flatten": true,
416 }),
417 None,
418 )
419 .await?;
420 let mapper_session_id = required_string(&attached, "/sessionId")?;
421
422 cdp.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
423 .await?;
424 cdp.send_command(
425 "Target.exposeDevToolsProtocol",
426 json!({
427 "bindingName": "cdp",
428 "targetId": mapper_target_id,
429 "inheritPermissions": true,
430 }),
431 None,
432 )
433 .await?;
434 cdp.send_command(
435 "Runtime.addBinding",
436 json!({
437 "name": "sendBidiResponse",
438 }),
439 Some(&mapper_session_id),
440 )
441 .await?;
442 cdp.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
443 .await?;
444 cdp.evaluate_expression(
445 &mapper_session_id,
446 &format!(
447 "window.runMapperInstance('{}')",
448 js_single_quote(&mapper_target_id)
449 ),
450 true,
451 )
452 .await?;
453
454 Ok(ChromiumBidiMapperInfo {
455 package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
456 mapper_target_id,
457 mapper_session_id,
458 note: format!(
459 "injected chromium-bidi mapper from pinned published chromium-bidi@{} artifact into hidden mapper target",
460 CHROMIUM_BIDI_NPM_VERSION
461 ),
462 })
463}
464
465pub async fn click_element_via_cdp(
466 cdp_websocket_url: &str,
467 target_id: &str,
468 css_selector: &str,
469) -> Result<ClickInfo, String> {
470 if css_selector.trim().is_empty() {
471 return Err("click_element command requires a non-empty css_selector".to_string());
472 }
473
474 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
475 let session_id = cdp.prepare_page_target_session(target_id).await?;
476 let discovered =
477 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
478 let center = discovered.first_center.ok_or_else(|| {
479 format!("element discovery did not return a clickable center for selector {css_selector}")
480 })?;
481 cdp.dispatch_mouse_click(&session_id, center.x, center.y)
482 .await?;
483 cdp.detach_from_target(&session_id).await?;
484
485 Ok(ClickInfo {
486 css_selector: css_selector.to_string(),
487 note: format!("clicked element via CDP mouse events using css selector {css_selector}"),
488 bidi_session_id: String::new(),
489 })
490}
491
492pub async fn count_elements_via_cdp(
493 cdp_websocket_url: &str,
494 target_id: &str,
495 css_selector: &str,
496) -> Result<ElementCountInfo, String> {
497 if css_selector.trim().is_empty() {
498 return Err("count_elements command requires a non-empty css_selector".to_string());
499 }
500
501 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
502 let session_id = cdp.prepare_page_target_session(target_id).await?;
503 let discovered =
504 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
505 cdp.detach_from_target(&session_id).await?;
506
507 Ok(ElementCountInfo {
508 css_selector: css_selector.to_string(),
509 count: discovered.count,
510 note: format!(
511 "counted {} element(s) matching css selector {css_selector}",
512 discovered.count
513 ),
514 })
515}
516
517pub async fn highlight_elements_via_cdp(
518 cdp_websocket_url: &str,
519 target_id: &str,
520 css_selector: &str,
521 duration_ms: u32,
522) -> Result<HighlightElementsInfo, String> {
523 if css_selector.trim().is_empty() {
524 return Err("highlight_elements command requires a non-empty css_selector".to_string());
525 }
526
527 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
528 let session_id = cdp.prepare_page_target_session(target_id).await?;
529 let discovered =
530 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
531
532 let selector_literal = serde_json::to_string(css_selector)
533 .map_err(|error| format!("failed to serialize css selector for highlight: {error}"))?;
534 let duration_ms = duration_ms.max(1);
535 cdp.evaluate_expression(
536 &session_id,
537 &format!(
538 "(() => {{
539 const selector = {selector_literal};
540 const durationMs = {duration_ms};
541 const elements = Array.from(document.querySelectorAll(selector));
542 for (const element of elements) {{
543 if (!(element instanceof HTMLElement)) {{
544 continue;
545 }}
546 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
547 const priorOutline = element.style.outline;
548 const priorOutlineOffset = element.style.outlineOffset;
549 const priorBackgroundColor = element.style.backgroundColor;
550 element.style.outline = '3px solid #ff5a36';
551 element.style.outlineOffset = '2px';
552 element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
553 window.setTimeout(() => {{
554 element.style.outline = priorOutline;
555 element.style.outlineOffset = priorOutlineOffset;
556 element.style.backgroundColor = priorBackgroundColor;
557 }}, durationMs);
558 }}
559 return elements.length;
560 }})()"
561 ),
562 true,
563 )
564 .await?;
565 cdp.detach_from_target(&session_id).await?;
566
567 Ok(HighlightElementsInfo {
568 css_selector: css_selector.to_string(),
569 count: discovered.count,
570 note: format!(
571 "highlighted {} element(s) matching css selector {css_selector} for {duration_ms}ms",
572 discovered.count
573 ),
574 })
575}
576
577pub async fn focus_element_via_cdp(
578 cdp_websocket_url: &str,
579 target_id: &str,
580 css_selector: &str,
581) -> Result<FocusInfo, String> {
582 require_non_empty_selector("focus_element", css_selector)?;
583 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
584 let session_id = cdp.prepare_page_target_session(target_id).await?;
585 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
586 cdp.detach_from_target(&session_id).await?;
587
588 Ok(FocusInfo {
589 css_selector: css_selector.to_string(),
590 note: format!("focused element matching css selector {css_selector}"),
591 })
592}
593
594pub async fn fill_element_via_cdp(
595 cdp_websocket_url: &str,
596 target_id: &str,
597 css_selector: &str,
598 value: &str,
599) -> Result<FillInfo, String> {
600 require_non_empty_selector("fill_element", css_selector)?;
601 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
602 let session_id = cdp.prepare_page_target_session(target_id).await?;
603 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
604 let selector_literal = json_string_literal(css_selector, "fill selector")?;
605 let value_literal = json_string_literal(value, "fill value")?;
606 cdp.evaluate_expression(
607 &session_id,
608 &dom_set_value_and_dispatch_events_js(&selector_literal, &value_literal),
609 true,
610 )
611 .await?;
612 cdp.detach_from_target(&session_id).await?;
613
614 Ok(FillInfo {
615 css_selector: css_selector.to_string(),
616 value: value.to_string(),
617 note: format!("filled element matching css selector {css_selector}"),
618 })
619}
620
621pub async fn hover_element_via_cdp(
622 cdp_websocket_url: &str,
623 target_id: &str,
624 css_selector: &str,
625) -> Result<HoverInfo, String> {
626 require_non_empty_selector("hover_element", css_selector)?;
627 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
628 let session_id = cdp.prepare_page_target_session(target_id).await?;
629 let discovered =
630 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
631 let center = discovered.first_center.ok_or_else(|| {
632 format!("element discovery did not return a hover center for selector {css_selector}")
633 })?;
634 cdp.dispatch_mouse_move(&session_id, center.x, center.y)
635 .await?;
636 cdp.detach_from_target(&session_id).await?;
637
638 Ok(HoverInfo {
639 css_selector: css_selector.to_string(),
640 note: format!("hovered element matching css selector {css_selector}"),
641 })
642}
643
644pub async fn press_key_via_cdp(
645 cdp_websocket_url: &str,
646 target_id: &str,
647 css_selector: &str,
648 key: &str,
649 text: Option<&str>,
650) -> Result<PressKeyInfo, String> {
651 require_non_empty_selector("press_key", css_selector)?;
652 if key.trim().is_empty() {
653 return Err("press_key command requires a non-empty key".to_string());
654 }
655
656 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
657 let session_id = cdp.prepare_page_target_session(target_id).await?;
658 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
659 cdp.dispatch_key_press(&session_id, key, text).await?;
660 cdp.detach_from_target(&session_id).await?;
661
662 Ok(PressKeyInfo {
663 css_selector: css_selector.to_string(),
664 key: key.to_string(),
665 note: format!("pressed key {key} on element matching css selector {css_selector}"),
666 })
667}
668
669pub async fn get_text_content_via_cdp(
670 cdp_websocket_url: &str,
671 target_id: &str,
672 css_selector: &str,
673) -> Result<TextInfo, String> {
674 get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "textContent")
675 .await
676 .map(|mut info| {
677 info.note = format!("resolved textContent for css selector {css_selector}");
678 info
679 })
680}
681
682pub async fn get_inner_text_via_cdp(
683 cdp_websocket_url: &str,
684 target_id: &str,
685 css_selector: &str,
686) -> Result<TextInfo, String> {
687 get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "innerText")
688 .await
689 .map(|mut info| {
690 info.note = format!("resolved innerText for css selector {css_selector}");
691 info
692 })
693}
694
695pub async fn wait_for_selector_via_cdp(
696 cdp_websocket_url: &str,
697 target_id: &str,
698 css_selector: &str,
699 visible: bool,
700) -> Result<WaitForSelectorInfo, String> {
701 require_non_empty_selector("wait_for_selector", css_selector)?;
702 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
703 let session_id = cdp.prepare_page_target_session(target_id).await?;
704 let discovered =
705 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
706 let success = if visible {
707 discovered.first_center.is_some()
708 } else {
709 discovered.count > 0
710 };
711 cdp.detach_from_target(&session_id).await?;
712
713 if !success {
714 return Err(if visible {
715 format!("no visible element matches css selector {css_selector}")
716 } else {
717 format!("no element matches css selector {css_selector}")
718 });
719 }
720
721 Ok(WaitForSelectorInfo {
722 css_selector: css_selector.to_string(),
723 visible,
724 note: if visible {
725 format!("visible element matched css selector {css_selector}")
726 } else {
727 format!("element matched css selector {css_selector}")
728 },
729 })
730}
731
732pub async fn click_element(
733 browser_session: &BrowserSessionHandle,
734 page_session: &PageSessionHandle,
735 css_selector: &str,
736) -> Result<ClickInfo, String> {
737 match (browser_session, page_session) {
738 (
739 BrowserSessionHandle::Chromium { cdp_websocket_url },
740 PageSessionHandle::Chromium { target_id, .. },
741 ) => click_element_via_cdp(cdp_websocket_url, target_id, css_selector).await,
742 (
743 BrowserSessionHandle::Firefox {
744 connection_id,
745 bidi_session_id,
746 },
747 PageSessionHandle::Firefox {
748 browsing_context_id,
749 },
750 ) => {
751 firefox_require_selector(css_selector, "click_element")?;
752 let mut sessions = firefox_session_guard(connection_id).await?;
753 let bidi = &mut sessions
754 .get_mut(connection_id)
755 .expect("Firefox session guard validated connection id")
756 .connection;
757 firefox_evaluate_void(
758 bidi,
759 browsing_context_id,
760 &format!(
761 "(() => {{
762 const selector = {};
763 const element = document.querySelector(selector);
764 if (!element) {{
765 throw new Error(`No element matches selector: ${{selector}}`);
766 }}
767 if (!(element instanceof HTMLElement)) {{
768 throw new Error(`Element is not clickable: ${{selector}}`);
769 }}
770 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
771 element.click();
772 }})()",
773 json_string_literal(css_selector, "click selector")?,
774 ),
775 )
776 .await?;
777 Ok(ClickInfo {
778 css_selector: css_selector.to_string(),
779 note: format!(
780 "clicked element via Firefox WebDriver BiDi using css selector {css_selector}"
781 ),
782 bidi_session_id: bidi_session_id.clone(),
783 })
784 }
785 _ => Err("browser/page backend mismatch while clicking".to_string()),
786 }
787}
788
789pub async fn count_elements(
790 browser_session: &BrowserSessionHandle,
791 page_session: &PageSessionHandle,
792 css_selector: &str,
793) -> Result<ElementCountInfo, String> {
794 match (browser_session, page_session) {
795 (
796 BrowserSessionHandle::Chromium { cdp_websocket_url },
797 PageSessionHandle::Chromium { target_id, .. },
798 ) => count_elements_via_cdp(cdp_websocket_url, target_id, css_selector).await,
799 (
800 BrowserSessionHandle::Firefox { connection_id, .. },
801 PageSessionHandle::Firefox {
802 browsing_context_id,
803 },
804 ) => {
805 firefox_require_selector(css_selector, "count_elements")?;
806 let mut sessions = firefox_session_guard(connection_id).await?;
807 let bidi = &mut sessions
808 .get_mut(connection_id)
809 .expect("Firefox session guard validated connection id")
810 .connection;
811 let count = firefox_evaluate_u32(
812 bidi,
813 browsing_context_id,
814 &format!(
815 "(() => document.querySelectorAll({}).length)()",
816 json_string_literal(css_selector, "count selector")?,
817 ),
818 )
819 .await?;
820 Ok(ElementCountInfo {
821 css_selector: css_selector.to_string(),
822 count,
823 note: format!("counted {count} element(s) matching css selector {css_selector}"),
824 })
825 }
826 _ => Err("browser/page backend mismatch while counting elements".to_string()),
827 }
828}
829
830pub async fn highlight_elements(
831 browser_session: &BrowserSessionHandle,
832 page_session: &PageSessionHandle,
833 css_selector: &str,
834 duration_ms: u64,
835) -> Result<HighlightElementsInfo, String> {
836 match (browser_session, page_session) {
837 (
838 BrowserSessionHandle::Chromium { cdp_websocket_url },
839 PageSessionHandle::Chromium { target_id, .. },
840 ) => {
841 highlight_elements_via_cdp(
842 cdp_websocket_url,
843 target_id,
844 css_selector,
845 u32::try_from(duration_ms).unwrap_or(u32::MAX),
846 )
847 .await
848 }
849 (
850 BrowserSessionHandle::Firefox { connection_id, .. },
851 PageSessionHandle::Firefox {
852 browsing_context_id,
853 },
854 ) => {
855 firefox_require_selector(css_selector, "highlight_elements")?;
856 let duration_ms = u32::try_from(duration_ms).unwrap_or(u32::MAX).max(1);
857 let mut sessions = firefox_session_guard(connection_id).await?;
858 let bidi = &mut sessions
859 .get_mut(connection_id)
860 .expect("Firefox session guard validated connection id")
861 .connection;
862 let count = firefox_evaluate_u32(
863 bidi,
864 browsing_context_id,
865 &format!(
866 "(() => {{
867 const selector = {};
868 const durationMs = {duration_ms};
869 const elements = Array.from(document.querySelectorAll(selector));
870 for (const element of elements) {{
871 if (!(element instanceof HTMLElement)) {{
872 continue;
873 }}
874 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
875 const priorOutline = element.style.outline;
876 const priorOutlineOffset = element.style.outlineOffset;
877 const priorBackgroundColor = element.style.backgroundColor;
878 element.style.outline = '3px solid #ff5a36';
879 element.style.outlineOffset = '2px';
880 element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
881 window.setTimeout(() => {{
882 element.style.outline = priorOutline;
883 element.style.outlineOffset = priorOutlineOffset;
884 element.style.backgroundColor = priorBackgroundColor;
885 }}, durationMs);
886 }}
887 return elements.length;
888 }})()",
889 json_string_literal(css_selector, "highlight selector")?,
890 ),
891 )
892 .await?;
893 Ok(HighlightElementsInfo {
894 css_selector: css_selector.to_string(),
895 count,
896 note: format!(
897 "highlighted {count} element(s) matching css selector {css_selector} for {duration_ms}ms"
898 ),
899 })
900 }
901 _ => Err("browser/page backend mismatch while highlighting elements".to_string()),
902 }
903}
904
905pub async fn focus_element(
906 browser_session: &BrowserSessionHandle,
907 page_session: &PageSessionHandle,
908 css_selector: &str,
909) -> Result<FocusInfo, String> {
910 match (browser_session, page_session) {
911 (
912 BrowserSessionHandle::Chromium { cdp_websocket_url },
913 PageSessionHandle::Chromium { target_id, .. },
914 ) => focus_element_via_cdp(cdp_websocket_url, target_id, css_selector).await,
915 (
916 BrowserSessionHandle::Firefox { connection_id, .. },
917 PageSessionHandle::Firefox {
918 browsing_context_id,
919 },
920 ) => {
921 firefox_require_selector(css_selector, "focus_element")?;
922 let mut sessions = firefox_session_guard(connection_id).await?;
923 let bidi = &mut sessions
924 .get_mut(connection_id)
925 .expect("Firefox session guard validated connection id")
926 .connection;
927 firefox_evaluate_void(
928 bidi,
929 browsing_context_id,
930 &format!(
931 "(() => {{
932 const selector = {};
933 const element = document.querySelector(selector);
934 if (!element) {{
935 throw new Error(`No element matches selector: ${{selector}}`);
936 }}
937 if (!(element instanceof HTMLElement)) {{
938 throw new Error(`Element is not focusable: ${{selector}}`);
939 }}
940 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
941 element.focus();
942 }})()",
943 json_string_literal(css_selector, "focus selector")?,
944 ),
945 )
946 .await?;
947 Ok(FocusInfo {
948 css_selector: css_selector.to_string(),
949 note: format!("focused element matching css selector {css_selector}"),
950 })
951 }
952 _ => Err("browser/page backend mismatch while focusing element".to_string()),
953 }
954}
955
956pub async fn fill_element(
957 browser_session: &BrowserSessionHandle,
958 page_session: &PageSessionHandle,
959 css_selector: &str,
960 value: &str,
961) -> Result<FillInfo, String> {
962 match (browser_session, page_session) {
963 (
964 BrowserSessionHandle::Chromium { cdp_websocket_url },
965 PageSessionHandle::Chromium { target_id, .. },
966 ) => fill_element_via_cdp(cdp_websocket_url, target_id, css_selector, value).await,
967 (
968 BrowserSessionHandle::Firefox { connection_id, .. },
969 PageSessionHandle::Firefox {
970 browsing_context_id,
971 },
972 ) => {
973 firefox_require_selector(css_selector, "fill_element")?;
974 let mut sessions = firefox_session_guard(connection_id).await?;
975 let bidi = &mut sessions
976 .get_mut(connection_id)
977 .expect("Firefox session guard validated connection id")
978 .connection;
979 firefox_evaluate_void(
980 bidi,
981 browsing_context_id,
982 &dom_set_value_and_dispatch_events_js(
983 &json_string_literal(css_selector, "fill selector")?,
984 &json_string_literal(value, "fill value")?,
985 ),
986 )
987 .await?;
988 Ok(FillInfo {
989 css_selector: css_selector.to_string(),
990 value: value.to_string(),
991 note: format!("filled element matching css selector {css_selector}"),
992 })
993 }
994 _ => Err("browser/page backend mismatch while filling element".to_string()),
995 }
996}
997
998pub async fn hover_element(
999 browser_session: &BrowserSessionHandle,
1000 page_session: &PageSessionHandle,
1001 css_selector: &str,
1002) -> Result<HoverInfo, String> {
1003 match (browser_session, page_session) {
1004 (
1005 BrowserSessionHandle::Chromium { cdp_websocket_url },
1006 PageSessionHandle::Chromium { target_id, .. },
1007 ) => hover_element_via_cdp(cdp_websocket_url, target_id, css_selector).await,
1008 (
1009 BrowserSessionHandle::Firefox { connection_id, .. },
1010 PageSessionHandle::Firefox {
1011 browsing_context_id,
1012 },
1013 ) => {
1014 firefox_require_selector(css_selector, "hover_element")?;
1015 let mut sessions = firefox_session_guard(connection_id).await?;
1016 let bidi = &mut sessions
1017 .get_mut(connection_id)
1018 .expect("Firefox session guard validated connection id")
1019 .connection;
1020 firefox_evaluate_void(
1021 bidi,
1022 browsing_context_id,
1023 &format!(
1024 "(() => {{
1025 const selector = {};
1026 const element = document.querySelector(selector);
1027 if (!element) {{
1028 throw new Error(`No element matches selector: ${{selector}}`);
1029 }}
1030 if (!(element instanceof HTMLElement)) {{
1031 throw new Error(`Element is not hoverable: ${{selector}}`);
1032 }}
1033 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
1034 for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) {{
1035 element.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, view: window }}));
1036 }}
1037 }})()",
1038 json_string_literal(css_selector, "hover selector")?,
1039 ),
1040 )
1041 .await?;
1042 Ok(HoverInfo {
1043 css_selector: css_selector.to_string(),
1044 note: format!("hovered element matching css selector {css_selector}"),
1045 })
1046 }
1047 _ => Err("browser/page backend mismatch while hovering element".to_string()),
1048 }
1049}
1050
1051pub async fn press_key(
1052 browser_session: &BrowserSessionHandle,
1053 page_session: &PageSessionHandle,
1054 css_selector: &str,
1055 key: &str,
1056 text: Option<&str>,
1057) -> Result<PressKeyInfo, String> {
1058 match (browser_session, page_session) {
1059 (
1060 BrowserSessionHandle::Chromium { cdp_websocket_url },
1061 PageSessionHandle::Chromium { target_id, .. },
1062 ) => press_key_via_cdp(cdp_websocket_url, target_id, css_selector, key, text).await,
1063 (
1064 BrowserSessionHandle::Firefox { connection_id, .. },
1065 PageSessionHandle::Firefox {
1066 browsing_context_id,
1067 },
1068 ) => {
1069 firefox_require_selector(css_selector, "press_key")?;
1070 if key.trim().is_empty() {
1071 return Err("press_key command requires a non-empty key".to_string());
1072 }
1073 let mut sessions = firefox_session_guard(connection_id).await?;
1074 let bidi = &mut sessions
1075 .get_mut(connection_id)
1076 .expect("Firefox session guard validated connection id")
1077 .connection;
1078 firefox_evaluate_void(
1079 bidi,
1080 browsing_context_id,
1081 &format!(
1082 "(() => {{
1083 const selector = {};
1084 const key = {};
1085 const text = {};
1086 const element = document.querySelector(selector);
1087 if (!element) {{
1088 throw new Error(`No element matches selector: ${{selector}}`);
1089 }}
1090 if (!(element instanceof HTMLElement)) {{
1091 throw new Error(`Element is not keyboard-targetable: ${{selector}}`);
1092 }}
1093 element.focus();
1094 element.dispatchEvent(new KeyboardEvent('keydown', {{ key, bubbles: true }}));
1095 if (text && 'value' in element) {{
1096 element.value += text;
1097 element.dispatchEvent(new Event('input', {{ bubbles: true }}));
1098 element.dispatchEvent(new Event('change', {{ bubbles: true }}));
1099 }}
1100 element.dispatchEvent(new KeyboardEvent('keyup', {{ key, bubbles: true }}));
1101 }})()",
1102 json_string_literal(css_selector, "press selector")?,
1103 json_string_literal(key, "press key")?,
1104 json_string_literal(text.unwrap_or(""), "press text")?,
1105 ),
1106 )
1107 .await?;
1108 Ok(PressKeyInfo {
1109 css_selector: css_selector.to_string(),
1110 key: key.to_string(),
1111 note: format!("pressed key {key} on element matching css selector {css_selector}"),
1112 })
1113 }
1114 _ => Err("browser/page backend mismatch while pressing a key".to_string()),
1115 }
1116}
1117
1118pub async fn get_text_content(
1119 browser_session: &BrowserSessionHandle,
1120 page_session: &PageSessionHandle,
1121 css_selector: &str,
1122) -> Result<TextInfo, String> {
1123 match (browser_session, page_session) {
1124 (
1125 BrowserSessionHandle::Chromium { cdp_websocket_url },
1126 PageSessionHandle::Chromium { target_id, .. },
1127 ) => get_text_content_via_cdp(cdp_websocket_url, target_id, css_selector).await,
1128 (
1129 BrowserSessionHandle::Firefox { connection_id, .. },
1130 PageSessionHandle::Firefox {
1131 browsing_context_id,
1132 },
1133 ) => {
1134 firefox_get_text(
1135 connection_id,
1136 browsing_context_id,
1137 css_selector,
1138 "textContent",
1139 )
1140 .await
1141 }
1142 _ => Err("browser/page backend mismatch while resolving textContent".to_string()),
1143 }
1144}
1145
1146pub async fn get_inner_text(
1147 browser_session: &BrowserSessionHandle,
1148 page_session: &PageSessionHandle,
1149 css_selector: &str,
1150) -> Result<TextInfo, String> {
1151 match (browser_session, page_session) {
1152 (
1153 BrowserSessionHandle::Chromium { cdp_websocket_url },
1154 PageSessionHandle::Chromium { target_id, .. },
1155 ) => get_inner_text_via_cdp(cdp_websocket_url, target_id, css_selector).await,
1156 (
1157 BrowserSessionHandle::Firefox { connection_id, .. },
1158 PageSessionHandle::Firefox {
1159 browsing_context_id,
1160 },
1161 ) => {
1162 firefox_get_text(
1163 connection_id,
1164 browsing_context_id,
1165 css_selector,
1166 "innerText",
1167 )
1168 .await
1169 }
1170 _ => Err("browser/page backend mismatch while resolving innerText".to_string()),
1171 }
1172}
1173
1174pub async fn wait_for_selector(
1175 browser_session: &BrowserSessionHandle,
1176 page_session: &PageSessionHandle,
1177 css_selector: &str,
1178 visible: bool,
1179) -> Result<WaitForSelectorInfo, String> {
1180 match (browser_session, page_session) {
1181 (
1182 BrowserSessionHandle::Chromium { cdp_websocket_url },
1183 PageSessionHandle::Chromium { target_id, .. },
1184 ) => wait_for_selector_via_cdp(cdp_websocket_url, target_id, css_selector, visible).await,
1185 (
1186 BrowserSessionHandle::Firefox { connection_id, .. },
1187 PageSessionHandle::Firefox {
1188 browsing_context_id,
1189 },
1190 ) => {
1191 firefox_require_selector(css_selector, "wait_for_selector")?;
1192 let mut sessions = firefox_session_guard(connection_id).await?;
1193 let bidi = &mut sessions
1194 .get_mut(connection_id)
1195 .expect("Firefox session guard validated connection id")
1196 .connection;
1197 let success = firefox_evaluate_bool(
1198 bidi,
1199 browsing_context_id,
1200 &format!(
1201 "(() => {{
1202 const selector = {};
1203 const element = document.querySelector(selector);
1204 if (!element) {{
1205 return false;
1206 }}
1207 if (!{}) {{
1208 return true;
1209 }}
1210 if (!(element instanceof Element)) {{
1211 return false;
1212 }}
1213 const rect = element.getBoundingClientRect();
1214 return !!(rect.width && rect.height);
1215 }})()",
1216 json_string_literal(css_selector, "wait selector")?,
1217 if visible { "true" } else { "false" },
1218 ),
1219 )
1220 .await?;
1221 if !success {
1222 return Err(if visible {
1223 format!("no visible element matches css selector {css_selector}")
1224 } else {
1225 format!("no element matches css selector {css_selector}")
1226 });
1227 }
1228 Ok(WaitForSelectorInfo {
1229 css_selector: css_selector.to_string(),
1230 visible,
1231 note: if visible {
1232 format!("visible element matched css selector {css_selector}")
1233 } else {
1234 format!("element matched css selector {css_selector}")
1235 },
1236 })
1237 }
1238 _ => Err("browser/page backend mismatch while waiting for selector".to_string()),
1239 }
1240}
1241
1242async fn discover_elements_via_cdp(
1243 cdp: &mut CdpConnection,
1244 session_id: &str,
1245 css_selector: &str,
1246 require_match: bool,
1247 focus_first_match: bool,
1248) -> Result<DiscoveredElements, String> {
1249 let selector_literal = serde_json::to_string(css_selector)
1250 .map_err(|error| format!("failed to serialize css selector for discovery: {error}"))?;
1251 let expression = format!(
1252 "(() => {{
1253 const selector = {selector_literal};
1254 const elements = Array.from(document.querySelectorAll(selector));
1255 const first = elements[0] ?? null;
1256 if (first) {{
1257 first.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
1258 if ({focus_first_match} && first instanceof HTMLElement) {{
1259 first.focus();
1260 }}
1261 }}
1262 if ({require_match} && !first) {{
1263 throw new Error(`No element matches selector: ${{selector}}`);
1264 }}
1265 if (first && !(first instanceof Element)) {{
1266 throw new Error(`Selector did not resolve to a DOM Element: ${{selector}}`);
1267 }}
1268 const rect = first ? first.getBoundingClientRect() : null;
1269 if (first && (!rect.width || !rect.height)) {{
1270 throw new Error(`Element matched by selector has zero size: ${{selector}}`);
1271 }}
1272 return {{
1273 count: elements.length,
1274 first: rect ? {{
1275 x: rect.left + (rect.width / 2),
1276 y: rect.top + (rect.height / 2)
1277 }} : null
1278 }};
1279 }})()"
1280 );
1281 let result = cdp
1282 .evaluate_expression(session_id, &expression, true)
1283 .await?;
1284 let count = json_u32(&result, "/result/value/count")?;
1285 let first_center = if result.pointer("/result/value/first").is_some()
1286 && !result
1287 .pointer("/result/value/first")
1288 .is_some_and(Value::is_null)
1289 {
1290 Some(ElementCenter {
1291 x: json_f64(&result, "/result/value/first/x")?,
1292 y: json_f64(&result, "/result/value/first/y")?,
1293 })
1294 } else {
1295 None
1296 };
1297
1298 Ok(DiscoveredElements {
1299 count,
1300 first_center,
1301 })
1302}
1303
1304async fn get_text_via_cdp(
1305 cdp_websocket_url: &str,
1306 target_id: &str,
1307 css_selector: &str,
1308 property: &str,
1309) -> Result<TextInfo, String> {
1310 require_non_empty_selector("get_text", css_selector)?;
1311 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
1312 let session_id = cdp.prepare_page_target_session(target_id).await?;
1313 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
1314 let selector_literal = json_string_literal(css_selector, "text selector")?;
1315 let property_literal = json_string_literal(property, "text property")?;
1316 let result = cdp
1317 .evaluate_expression(
1318 &session_id,
1319 &format!(
1320 "(() => {{
1321 const selector = {selector_literal};
1322 const property = {property_literal};
1323 const element = document.querySelector(selector);
1324 if (!element) {{
1325 throw new Error(`No element matches selector: ${{selector}}`);
1326 }}
1327 const value = element[property];
1328 return typeof value === 'string' ? value : '';
1329 }})()"
1330 ),
1331 true,
1332 )
1333 .await?;
1334 cdp.detach_from_target(&session_id).await?;
1335
1336 Ok(TextInfo {
1337 css_selector: css_selector.to_string(),
1338 text: json_string(&result, "/result/value")?,
1339 note: String::new(),
1340 })
1341}
1342
1343fn dom_set_value_and_dispatch_events_js(selector_literal: &str, value_literal: &str) -> String {
1344 format!(
1345 "(() => {{
1346 const selector = {selector_literal};
1347 const nextValue = {value_literal};
1348 const element = document.querySelector(selector);
1349 if (!element) {{
1350 throw new Error(`No element matches selector: ${{selector}}`);
1351 }}
1352 if (!(element instanceof HTMLElement)) {{
1353 throw new Error(`Element is not editable: ${{selector}}`);
1354 }}
1355
1356 element.focus();
1357
1358 if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {{
1359 const prototype = element instanceof HTMLInputElement
1360 ? HTMLInputElement.prototype
1361 : HTMLTextAreaElement.prototype;
1362 const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
1363 const setter = descriptor?.set;
1364 if (!setter) {{
1365 throw new Error(`Element value setter is unavailable: ${{selector}}`);
1366 }}
1367 setter.call(element, nextValue);
1368 }} else if (element.isContentEditable) {{
1369 element.textContent = nextValue;
1370 }} else {{
1371 throw new Error(`Element does not support fill: ${{selector}}`);
1372 }}
1373
1374 const inputEvent = typeof InputEvent === 'function'
1375 ? new InputEvent('input', {{
1376 bubbles: true,
1377 composed: true,
1378 data: nextValue,
1379 inputType: 'insertText'
1380 }})
1381 : new Event('input', {{ bubbles: true, composed: true }});
1382 element.dispatchEvent(inputEvent);
1383 element.dispatchEvent(new Event('change', {{ bubbles: true, composed: true }}));
1384 return true;
1385 }})()"
1386 )
1387}
1388
1389pub async fn resolve_bidi_context_for_tab(
1390 cdp_websocket_url: &str,
1391 existing_mapper_target_id: Option<&str>,
1392 existing_context_id: Option<&str>,
1393 current_url: Option<&str>,
1394) -> Result<(String, ChromiumBidiMapperInfo), String> {
1395 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
1396 let mapper = cdp
1397 .ensure_chromium_bidi_mapper(existing_mapper_target_id)
1398 .await?;
1399 let context_id = cdp
1400 .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, current_url)
1401 .await?;
1402
1403 Ok((
1404 context_id,
1405 ChromiumBidiMapperInfo {
1406 package_version: mapper.package_version,
1407 mapper_target_id: mapper.mapper_target_id,
1408 mapper_session_id: mapper.mapper_session_id,
1409 note: "resolved BiDi browsing context for tab".to_string(),
1410 },
1411 ))
1412}
1413
1414#[cfg(target_os = "macos")]
1415fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1416 let browser_binary = chrome_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
1417 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string()
1418 });
1419 launch_chrome_with_cdp(&browser_binary)
1420}
1421
1422#[cfg(target_os = "linux")]
1423fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1424 if let Some(chrome_binary) = chrome_binary {
1425 return launch_chrome_with_cdp(chrome_binary);
1426 }
1427
1428 let candidates = ["google-chrome", "chromium", "chromium-browser"];
1429
1430 for candidate in candidates {
1431 match launch_chrome_with_cdp(candidate) {
1432 Ok(launch_info) => return Ok(launch_info),
1433 Err(error) => {
1434 if error.contains("No such file or directory")
1435 || error.contains("os error 2")
1436 || error.contains("not found")
1437 {
1438 continue;
1439 }
1440 return Err(format!("failed to launch {candidate}: {error}"));
1441 }
1442 }
1443 }
1444
1445 Err("could not find a Chrome-compatible browser binary".to_string())
1446}
1447
1448#[cfg(target_os = "windows")]
1449fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1450 if let Some(chrome_binary) = chrome_binary {
1451 return launch_chrome_with_cdp(chrome_binary);
1452 }
1453
1454 launch_chrome_with_cdp("chrome")
1455}
1456
1457#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1458fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
1459 match chrome_binary {
1460 Some(chrome_binary) => launch_chrome_with_cdp(chrome_binary),
1461 None => Err("Chrome launching is not supported on this platform yet".to_string()),
1462 }
1463}
1464
1465fn launch_chrome_with_cdp(browser_binary: &str) -> Result<ChromeLaunchInfo, String> {
1466 let user_data_dir = create_chrome_user_data_dir()?;
1467 let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
1468
1469 let child = Command::new(browser_binary)
1470 .args([
1471 "--new-window",
1472 "--remote-debugging-port=0",
1473 "--no-first-run",
1474 "--no-default-browser-check",
1475 "--disable-sync",
1476 &format!("--user-data-dir={user_data_dir_str}"),
1477 ])
1478 .spawn()
1479 .map_err(|error| format!("failed to launch Chrome binary {browser_binary}: {error}"))?;
1480 let process_id = child.id();
1481
1482 let cdp_websocket_url = wait_for_cdp_endpoint(&user_data_dir)?;
1483
1484 Ok(ChromeLaunchInfo {
1485 browser: browser_binary.to_string(),
1486 note: "launched Chrome window with CDP enabled".to_string(),
1487 cdp_websocket_url,
1488 user_data_dir: user_data_dir_str,
1489 process_id,
1490 })
1491}
1492
1493fn create_chrome_user_data_dir() -> Result<PathBuf, String> {
1494 let timestamp = SystemTime::now()
1495 .duration_since(UNIX_EPOCH)
1496 .map_err(|error| format!("failed to compute timestamp: {error}"))?
1497 .as_millis();
1498 let pid = std::process::id();
1499 let dir = std::env::temp_dir().join(format!("allwright-chrome-{pid}-{timestamp}"));
1500 fs::create_dir_all(&dir).map_err(|error| {
1501 format!(
1502 "failed to create Chrome user data dir {}: {error}",
1503 dir.display()
1504 )
1505 })?;
1506 Ok(dir)
1507}
1508
1509fn wait_for_cdp_endpoint(user_data_dir: &std::path::Path) -> Result<String, String> {
1510 let active_port_file = user_data_dir.join("DevToolsActivePort");
1511
1512 for _ in 0..100 {
1513 if active_port_file.exists() {
1514 let contents = fs::read_to_string(&active_port_file).map_err(|error| {
1515 format!(
1516 "failed to read DevToolsActivePort file {}: {error}",
1517 active_port_file.display()
1518 )
1519 })?;
1520 return parse_devtools_active_port(&contents);
1521 }
1522 thread::sleep(StdDuration::from_millis(50));
1523 }
1524
1525 Err(format!(
1526 "timed out waiting for DevToolsActivePort in {}",
1527 user_data_dir.display()
1528 ))
1529}
1530
1531fn parse_devtools_active_port(contents: &str) -> Result<String, String> {
1532 let mut lines = contents.lines();
1533 let port = lines
1534 .next()
1535 .map(str::trim)
1536 .filter(|value| !value.is_empty())
1537 .ok_or_else(|| "DevToolsActivePort missing port line".to_string())?;
1538 let browser_path = lines
1539 .next()
1540 .map(str::trim)
1541 .filter(|value| !value.is_empty())
1542 .ok_or_else(|| "DevToolsActivePort missing websocket path line".to_string())?;
1543
1544 Ok(format!("ws://127.0.0.1:{port}{browser_path}"))
1545}
1546
1547#[cfg(target_os = "macos")]
1548fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1549 let browser_binary = browser_binary
1550 .map(ToOwned::to_owned)
1551 .unwrap_or_else(|| "/Applications/Firefox.app/Contents/MacOS/firefox".to_string());
1552 launch_firefox_with_remote_agent(&browser_binary)
1553}
1554
1555#[cfg(target_os = "linux")]
1556fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1557 if let Some(browser_binary) = browser_binary {
1558 return launch_firefox_with_remote_agent(browser_binary);
1559 }
1560
1561 for candidate in ["firefox", "firefox-esr"] {
1562 match launch_firefox_with_remote_agent(candidate) {
1563 Ok(launch_info) => return Ok(launch_info),
1564 Err(error)
1565 if error.contains("No such file or directory")
1566 || error.contains("os error 2")
1567 || error.contains("not found") => {}
1568 Err(error) => return Err(format!("failed to launch {candidate}: {error}")),
1569 }
1570 }
1571
1572 Err("could not find a Firefox browser binary".to_string())
1573}
1574
1575#[cfg(target_os = "windows")]
1576fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1577 let browser_binary = browser_binary.unwrap_or("firefox");
1578 launch_firefox_with_remote_agent(browser_binary)
1579}
1580
1581#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1582fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
1583 match browser_binary {
1584 Some(browser_binary) => launch_firefox_with_remote_agent(browser_binary),
1585 None => Err("Firefox launching is not supported on this platform yet".to_string()),
1586 }
1587}
1588
1589fn launch_firefox_with_remote_agent(browser_binary: &str) -> Result<BrowserLaunchInfo, String> {
1590 let user_data_dir = create_firefox_user_data_dir()?;
1591 let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
1592 let remote_port = choose_loopback_port()?;
1593
1594 let child = Command::new(browser_binary)
1595 .args([
1596 "--new-instance",
1597 "--no-remote",
1598 "--remote-debugging-port",
1599 &remote_port.to_string(),
1600 "--profile",
1601 &user_data_dir_str,
1602 "about:blank",
1603 ])
1604 .spawn()
1605 .map_err(|error| format!("failed to launch Firefox binary {browser_binary}: {error}"))?;
1606 let process_id = child.id();
1607
1608 let bootstrap_ws_url = format!("ws://127.0.0.1:{remote_port}/session");
1609 let (connection_id, bidi_session_id, initial_context_id) = block_on_plugin_future(async {
1610 let mut bidi = wait_for_bidi_endpoint(&bootstrap_ws_url).await?;
1611 let session = bidi
1612 .send_command(
1613 "session.new",
1614 json!({
1615 "capabilities": {
1616 "alwaysMatch": {
1617 "acceptInsecureCerts": true
1618 }
1619 }
1620 }),
1621 )
1622 .await?;
1623 let session_id = required_string(&session, "/result/sessionId")?;
1624 let initial_context_id = bidi.wait_for_top_level_context().await?;
1625 let connection_id = format!("firefox-bidi-{process_id}-{session_id}");
1626 firefox_session_registry().lock().await.insert(
1627 connection_id.clone(),
1628 FirefoxSessionState {
1629 process_id,
1630 connection: bidi,
1631 },
1632 );
1633 Ok((connection_id, session_id, initial_context_id))
1634 })?;
1635
1636 Ok(BrowserLaunchInfo {
1637 browser_kind: BrowserKind::Firefox,
1638 browser: browser_binary.to_string(),
1639 note: "launched Firefox window with native WebDriver BiDi enabled".to_string(),
1640 user_data_dir: user_data_dir_str,
1641 process_id,
1642 browser_session: BrowserSessionHandle::Firefox {
1643 connection_id,
1644 bidi_session_id: bidi_session_id.clone(),
1645 },
1646 initial_page: PageInfo {
1647 note: "tracked initial Firefox tab via native WebDriver BiDi".to_string(),
1648 page_session: PageSessionHandle::Firefox {
1649 browsing_context_id: initial_context_id,
1650 },
1651 },
1652 })
1653}
1654
1655fn create_firefox_user_data_dir() -> Result<PathBuf, String> {
1656 let timestamp = SystemTime::now()
1657 .duration_since(UNIX_EPOCH)
1658 .map_err(|error| format!("failed to compute timestamp: {error}"))?
1659 .as_millis();
1660 let pid = std::process::id();
1661 let dir = std::env::temp_dir().join(format!("allwright-firefox-{pid}-{timestamp}"));
1662 fs::create_dir_all(&dir).map_err(|error| {
1663 format!(
1664 "failed to create Firefox profile dir {}: {error}",
1665 dir.display()
1666 )
1667 })?;
1668 Ok(dir)
1669}
1670
1671fn choose_loopback_port() -> Result<u16, String> {
1672 let listener = TcpListener::bind("127.0.0.1:0")
1673 .map_err(|error| format!("failed to reserve a loopback port for Firefox: {error}"))?;
1674 let port = listener
1675 .local_addr()
1676 .map_err(|error| format!("failed to read reserved loopback port: {error}"))?
1677 .port();
1678 drop(listener);
1679 Ok(port)
1680}
1681
1682async fn wait_for_bidi_endpoint(ws_url: &str) -> Result<BidiConnection, String> {
1683 let mut last_error = None;
1684 for _ in 0..100 {
1685 match BidiConnection::connect(ws_url).await {
1686 Ok(connection) => return Ok(connection),
1687 Err(error) => {
1688 last_error = Some(error);
1689 sleep(Duration::from_millis(50)).await;
1690 }
1691 }
1692 }
1693
1694 Err(format!(
1695 "timed out waiting for WebDriver BiDi endpoint {ws_url}: {}",
1696 last_error.unwrap_or_else(|| "unknown error".to_string())
1697 ))
1698}
1699
1700fn firefox_session_registry() -> &'static Mutex<HashMap<String, FirefoxSessionState>> {
1701 FIREFOX_SESSION_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1702}
1703
1704async fn firefox_session_guard(
1705 connection_id: &str,
1706) -> Result<MutexGuard<'static, HashMap<String, FirefoxSessionState>>, String> {
1707 let registry = firefox_session_registry();
1708 let sessions = registry.lock().await;
1709 if sessions.contains_key(connection_id) {
1710 Ok(sessions)
1711 } else {
1712 Err(format!(
1713 "Firefox automation session {connection_id} is not available"
1714 ))
1715 }
1716}
1717
1718fn firefox_require_selector(css_selector: &str, command_name: &str) -> Result<(), String> {
1719 if css_selector.trim().is_empty() {
1720 Err(format!(
1721 "{command_name} command requires a non-empty css_selector"
1722 ))
1723 } else {
1724 Ok(())
1725 }
1726}
1727
1728async fn firefox_evaluate_json(
1729 bidi: &mut BidiConnection,
1730 browsing_context_id: &str,
1731 expression: &str,
1732) -> Result<Value, String> {
1733 let result = bidi
1734 .send_command(
1735 "script.evaluate",
1736 json!({
1737 "expression": expression,
1738 "target": {
1739 "context": browsing_context_id,
1740 },
1741 "awaitPromise": true,
1742 }),
1743 )
1744 .await?;
1745 let result_type = json_string(&result, "/result/type")?;
1746 if result_type != "success" {
1747 let details = result
1748 .pointer("/result/exceptionDetails")
1749 .cloned()
1750 .unwrap_or(Value::Null);
1751 return Err(format!("Firefox script.evaluate failed: {details}"));
1752 }
1753 let value = json_string(&result, "/result/result/value")?;
1754 serde_json::from_str::<Value>(&value)
1755 .map_err(|error| format!("failed to parse Firefox script result JSON: {error}"))
1756}
1757
1758async fn firefox_evaluate_void(
1759 bidi: &mut BidiConnection,
1760 browsing_context_id: &str,
1761 expression: &str,
1762) -> Result<(), String> {
1763 firefox_evaluate_json(
1764 bidi,
1765 browsing_context_id,
1766 &format!("JSON.stringify((() => {{ {expression}; return null; }})())"),
1767 )
1768 .await
1769 .map(|_| ())
1770}
1771
1772async fn firefox_evaluate_u32(
1773 bidi: &mut BidiConnection,
1774 browsing_context_id: &str,
1775 expression: &str,
1776) -> Result<u32, String> {
1777 let value = firefox_evaluate_json(
1778 bidi,
1779 browsing_context_id,
1780 &format!("JSON.stringify({expression})"),
1781 )
1782 .await?;
1783 if let Some(number) = value.as_u64() {
1784 return u32::try_from(number)
1785 .map_err(|_| format!("Firefox script result exceeds u32: {number}"));
1786 }
1787 if let Some(number) = value.as_i64() {
1788 return u32::try_from(number)
1789 .map_err(|_| format!("Firefox script result is out of range for u32: {number}"));
1790 }
1791 Err(format!(
1792 "expected numeric Firefox script result, found {value}"
1793 ))
1794}
1795
1796async fn firefox_evaluate_bool(
1797 bidi: &mut BidiConnection,
1798 browsing_context_id: &str,
1799 expression: &str,
1800) -> Result<bool, String> {
1801 let value = firefox_evaluate_json(
1802 bidi,
1803 browsing_context_id,
1804 &format!("JSON.stringify({expression})"),
1805 )
1806 .await?;
1807 value
1808 .as_bool()
1809 .ok_or_else(|| format!("expected boolean Firefox script result, found {value}"))
1810}
1811
1812async fn firefox_get_text(
1813 connection_id: &str,
1814 browsing_context_id: &str,
1815 css_selector: &str,
1816 property: &str,
1817) -> Result<TextInfo, String> {
1818 firefox_require_selector(css_selector, "get_text")?;
1819 let mut sessions = firefox_session_guard(connection_id).await?;
1820 let bidi = &mut sessions
1821 .get_mut(connection_id)
1822 .expect("Firefox session guard validated connection id")
1823 .connection;
1824 let value = firefox_evaluate_json(
1825 bidi,
1826 browsing_context_id,
1827 &format!(
1828 "JSON.stringify((() => {{
1829 const selector = {};
1830 const property = {};
1831 const element = document.querySelector(selector);
1832 if (!element) {{
1833 throw new Error(`No element matches selector: ${{selector}}`);
1834 }}
1835 const value = element[property];
1836 return typeof value === 'string' ? value : '';
1837 }})())",
1838 json_string_literal(css_selector, "text selector")?,
1839 json_string_literal(property, "text property")?,
1840 ),
1841 )
1842 .await?;
1843
1844 Ok(TextInfo {
1845 css_selector: css_selector.to_string(),
1846 text: value.as_str().map(str::to_string).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!(
2548 "WebDriver BiDi command {method} returned {error}: {message_text}"
2549 ));
2550 }
2551
2552 return Ok(message);
2553 }
2554 }
2555
2556 async fn wait_for_top_level_context(&mut self) -> Result<String, String> {
2557 for _ in 0..100 {
2558 let tree = self
2559 .send_command(
2560 "browsingContext.getTree",
2561 json!({
2562 "maxDepth": 0,
2563 }),
2564 )
2565 .await?;
2566 if let Some(context_id) = tree
2567 .pointer("/result/contexts")
2568 .and_then(Value::as_array)
2569 .and_then(|contexts| contexts.first())
2570 .and_then(|context| context.get("context"))
2571 .and_then(Value::as_str)
2572 .filter(|value| !value.trim().is_empty())
2573 {
2574 return Ok(context_id.to_string());
2575 }
2576 sleep(Duration::from_millis(50)).await;
2577 }
2578
2579 Err("timed out waiting for an initial Firefox browsing context".to_string())
2580 }
2581
2582 async fn next_json_message(&mut self) -> Result<Value, String> {
2583 loop {
2584 let message = self
2585 .socket
2586 .next()
2587 .await
2588 .ok_or_else(|| "WebDriver BiDi websocket closed unexpectedly".to_string())?
2589 .map_err(|error| {
2590 format!("failed to read WebDriver BiDi websocket message: {error}")
2591 })?;
2592
2593 match message {
2594 Message::Text(text) => {
2595 let value = serde_json::from_str::<Value>(&text).map_err(|error| {
2596 format!("failed to parse WebDriver BiDi JSON message: {error}")
2597 })?;
2598 return Ok(value);
2599 }
2600 Message::Binary(bytes) => {
2601 let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
2602 format!("failed to parse WebDriver BiDi JSON message: {error}")
2603 })?;
2604 return Ok(value);
2605 }
2606 Message::Ping(payload) => {
2607 self.socket
2608 .send(Message::Pong(payload))
2609 .await
2610 .map_err(|error| {
2611 format!("failed to reply to WebDriver BiDi ping: {error}")
2612 })?;
2613 }
2614 Message::Pong(_) => {}
2615 Message::Frame(_) => {}
2616 Message::Close(frame) => {
2617 return Err(format!("WebDriver BiDi websocket closed: {frame:?}"));
2618 }
2619 }
2620 }
2621 }
2622}
2623
2624fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
2625 value
2626 .pointer(pointer)
2627 .and_then(Value::as_str)
2628 .map(str::to_string)
2629 .filter(|value| !value.is_empty())
2630 .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
2631}
2632
2633fn js_single_quote(value: &str) -> String {
2634 value.replace('\\', "\\\\").replace('\'', "\\'")
2635}
2636
2637fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
2638 let raw = value
2639 .pointer(pointer)
2640 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
2641
2642 if let Some(number) = raw.as_u64() {
2643 return u32::try_from(number)
2644 .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
2645 }
2646
2647 if let Some(number) = raw.as_i64() {
2648 return u32::try_from(number).map_err(|_| {
2649 format!("numeric JSON value at {pointer} is out of range for u32: {number}")
2650 });
2651 }
2652
2653 Err(format!(
2654 "expected numeric JSON value at {pointer}, found {raw}"
2655 ))
2656}
2657
2658fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
2659 let raw = value
2660 .pointer(pointer)
2661 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
2662
2663 raw.as_f64()
2664 .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
2665}
2666
2667fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
2668 value
2669 .pointer(pointer)
2670 .and_then(Value::as_str)
2671 .map(str::to_string)
2672 .ok_or_else(|| format!("expected string JSON value at {pointer}"))
2673}
2674
2675fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
2676 serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
2677}
2678
2679fn require_non_empty_selector(command_name: &str, css_selector: &str) -> Result<(), String> {
2680 if css_selector.trim().is_empty() {
2681 Err(format!(
2682 "{command_name} command requires a non-empty css_selector"
2683 ))
2684 } else {
2685 Ok(())
2686 }
2687}
2688
2689fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
2690where
2691 F: Future<Output = Result<T, String>>,
2692{
2693 let runtime = plugin_runtime()?;
2694 runtime.block_on(future)
2695}
2696
2697fn plugin_runtime() -> Result<&'static tokio::runtime::Runtime, String> {
2698 if let Some(runtime) = PLUGIN_RUNTIME.get() {
2699 return Ok(runtime);
2700 }
2701
2702 let runtime = tokio::runtime::Builder::new_multi_thread()
2703 .enable_all()
2704 .worker_threads(2)
2705 .thread_name("allwright-web-plugin")
2706 .build()
2707 .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
2708 let _ = PLUGIN_RUNTIME.set(runtime);
2709 PLUGIN_RUNTIME
2710 .get()
2711 .ok_or_else(|| "failed to initialize plugin runtime".to_string())
2712}
2713
2714fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
2715 let envelope = match result {
2716 Ok(result) => PluginEnvelope {
2717 ok: true,
2718 result: Some(result),
2719 error: None,
2720 },
2721 Err(error) => PluginEnvelope {
2722 ok: false,
2723 result: None,
2724 error: Some(error),
2725 },
2726 };
2727
2728 let json = match serde_json::to_string(&envelope) {
2729 Ok(json) => json,
2730 Err(error) => {
2731 let fallback = PluginEnvelope {
2732 ok: false,
2733 result: None,
2734 error: Some(format!("failed to serialize plugin response: {error}")),
2735 };
2736 serde_json::to_string(&fallback).unwrap_or_else(|_| {
2737 "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
2738 .to_string()
2739 })
2740 }
2741 };
2742
2743 CString::new(json).unwrap().into_raw()
2744}
2745
2746fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
2747 match command {
2748 PluginCommand::LaunchBrowser {
2749 browser_kind,
2750 browser_binary,
2751 } => {
2752 launch_browser(browser_kind, browser_binary.as_deref()).map(PluginResult::LaunchBrowser)
2753 }
2754 PluginCommand::OpenPage { browser_session } => block_on_plugin_future(async move {
2755 open_page(&browser_session)
2756 .await
2757 .map(PluginResult::OpenPage)
2758 }),
2759 PluginCommand::ClosePage {
2760 browser_session,
2761 page_session,
2762 } => block_on_plugin_future(async move {
2763 close_page(&browser_session, &page_session).await?;
2764 Ok(PluginResult::ClosePage)
2765 }),
2766 PluginCommand::NavigatePage {
2767 browser_session,
2768 page_session,
2769 url,
2770 } => block_on_plugin_future(async move {
2771 navigate_page(&browser_session, &page_session, &url)
2772 .await
2773 .map(PluginResult::NavigatePage)
2774 }),
2775 PluginCommand::ClickElement {
2776 browser_session,
2777 page_session,
2778 css_selector,
2779 } => block_on_plugin_future(async move {
2780 click_element(&browser_session, &page_session, &css_selector)
2781 .await
2782 .map(PluginResult::ClickElement)
2783 }),
2784 PluginCommand::CountElements {
2785 browser_session,
2786 page_session,
2787 css_selector,
2788 } => block_on_plugin_future(async move {
2789 count_elements(&browser_session, &page_session, &css_selector)
2790 .await
2791 .map(PluginResult::CountElements)
2792 }),
2793 PluginCommand::HighlightElements {
2794 browser_session,
2795 page_session,
2796 css_selector,
2797 duration_ms,
2798 } => block_on_plugin_future(async move {
2799 highlight_elements(&browser_session, &page_session, &css_selector, duration_ms)
2800 .await
2801 .map(PluginResult::HighlightElements)
2802 }),
2803 PluginCommand::FocusElement {
2804 browser_session,
2805 page_session,
2806 css_selector,
2807 } => block_on_plugin_future(async move {
2808 focus_element(&browser_session, &page_session, &css_selector)
2809 .await
2810 .map(PluginResult::FocusElement)
2811 }),
2812 PluginCommand::FillElement {
2813 browser_session,
2814 page_session,
2815 css_selector,
2816 value,
2817 } => block_on_plugin_future(async move {
2818 fill_element(&browser_session, &page_session, &css_selector, &value)
2819 .await
2820 .map(PluginResult::FillElement)
2821 }),
2822 PluginCommand::HoverElement {
2823 browser_session,
2824 page_session,
2825 css_selector,
2826 } => block_on_plugin_future(async move {
2827 hover_element(&browser_session, &page_session, &css_selector)
2828 .await
2829 .map(PluginResult::HoverElement)
2830 }),
2831 PluginCommand::PressKey {
2832 browser_session,
2833 page_session,
2834 css_selector,
2835 key,
2836 text,
2837 } => block_on_plugin_future(async move {
2838 press_key(
2839 &browser_session,
2840 &page_session,
2841 &css_selector,
2842 &key,
2843 text.as_deref(),
2844 )
2845 .await
2846 .map(PluginResult::PressKey)
2847 }),
2848 PluginCommand::GetTextContent {
2849 browser_session,
2850 page_session,
2851 css_selector,
2852 } => block_on_plugin_future(async move {
2853 get_text_content(&browser_session, &page_session, &css_selector)
2854 .await
2855 .map(PluginResult::GetTextContent)
2856 }),
2857 PluginCommand::GetInnerText {
2858 browser_session,
2859 page_session,
2860 css_selector,
2861 } => block_on_plugin_future(async move {
2862 get_inner_text(&browser_session, &page_session, &css_selector)
2863 .await
2864 .map(PluginResult::GetInnerText)
2865 }),
2866 PluginCommand::WaitForSelector {
2867 browser_session,
2868 page_session,
2869 css_selector,
2870 visible,
2871 } => block_on_plugin_future(async move {
2872 wait_for_selector(&browser_session, &page_session, &css_selector, visible)
2873 .await
2874 .map(PluginResult::WaitForSelector)
2875 }),
2876 PluginCommand::OpenChromeWindow { chrome_binary } => {
2877 open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
2878 }
2879 PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
2880 block_on_plugin_future(async move {
2881 let result = discover_initial_tab(&cdp_websocket_url).await?;
2882 Ok(PluginResult::DiscoverInitialTab(result))
2883 })
2884 }
2885 PluginCommand::OpenChromeTab { cdp_websocket_url } => block_on_plugin_future(async move {
2886 let result = open_chrome_tab(&cdp_websocket_url).await?;
2887 Ok(PluginResult::OpenChromeTab(result))
2888 }),
2889 PluginCommand::CloseBrowserProcess { process_id } => {
2890 close_browser_process(process_id)?;
2891 Ok(PluginResult::CloseBrowserProcess)
2892 }
2893 PluginCommand::CloseChromeTab {
2894 cdp_websocket_url,
2895 target_id,
2896 } => block_on_plugin_future(async move {
2897 close_chrome_tab(&cdp_websocket_url, &target_id).await?;
2898 Ok(PluginResult::CloseChromeTab)
2899 }),
2900 PluginCommand::NavigateChromeTab {
2901 cdp_websocket_url,
2902 target_id,
2903 url,
2904 } => block_on_plugin_future(async move {
2905 let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
2906 Ok(PluginResult::NavigateChromeTab(result))
2907 }),
2908 PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
2909 block_on_plugin_future(async move {
2910 let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
2911 Ok(PluginResult::InjectChromiumBidiMapper(result))
2912 })
2913 }
2914 PluginCommand::ResolveBidiContextForTab {
2915 cdp_websocket_url,
2916 mapper_target_id,
2917 browsing_context_id,
2918 url,
2919 } => block_on_plugin_future(async move {
2920 let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
2921 &cdp_websocket_url,
2922 mapper_target_id.as_deref(),
2923 browsing_context_id.as_deref(),
2924 url.as_deref(),
2925 )
2926 .await?;
2927 Ok(PluginResult::ResolveBidiContextForTab {
2928 browsing_context_id: resolved_browsing_context_id,
2929 mapper,
2930 })
2931 }),
2932 PluginCommand::ClickElementViaCdp {
2933 cdp_websocket_url,
2934 target_id,
2935 css_selector,
2936 } => block_on_plugin_future(async move {
2937 let result =
2938 click_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2939 Ok(PluginResult::ClickElementViaCdp(result))
2940 }),
2941 PluginCommand::CountElementsViaCdp {
2942 cdp_websocket_url,
2943 target_id,
2944 css_selector,
2945 } => block_on_plugin_future(async move {
2946 let result =
2947 count_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2948 Ok(PluginResult::CountElementsViaCdp(result))
2949 }),
2950 PluginCommand::HighlightElementsViaCdp {
2951 cdp_websocket_url,
2952 target_id,
2953 css_selector,
2954 duration_ms,
2955 } => block_on_plugin_future(async move {
2956 let duration_ms = u32::try_from(duration_ms)
2957 .map_err(|_| format!("highlight duration {duration_ms} exceeds u32"))?;
2958 let result = highlight_elements_via_cdp(
2959 &cdp_websocket_url,
2960 &target_id,
2961 &css_selector,
2962 duration_ms,
2963 )
2964 .await?;
2965 Ok(PluginResult::HighlightElementsViaCdp(result))
2966 }),
2967 PluginCommand::FocusElementViaCdp {
2968 cdp_websocket_url,
2969 target_id,
2970 css_selector,
2971 } => block_on_plugin_future(async move {
2972 let result =
2973 focus_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2974 Ok(PluginResult::FocusElementViaCdp(result))
2975 }),
2976 PluginCommand::FillElementViaCdp {
2977 cdp_websocket_url,
2978 target_id,
2979 css_selector,
2980 value,
2981 } => block_on_plugin_future(async move {
2982 let result =
2983 fill_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector, &value).await?;
2984 Ok(PluginResult::FillElementViaCdp(result))
2985 }),
2986 PluginCommand::HoverElementViaCdp {
2987 cdp_websocket_url,
2988 target_id,
2989 css_selector,
2990 } => block_on_plugin_future(async move {
2991 let result =
2992 hover_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
2993 Ok(PluginResult::HoverElementViaCdp(result))
2994 }),
2995 PluginCommand::PressKeyViaCdp {
2996 cdp_websocket_url,
2997 target_id,
2998 css_selector,
2999 key,
3000 text,
3001 } => block_on_plugin_future(async move {
3002 let result = press_key_via_cdp(
3003 &cdp_websocket_url,
3004 &target_id,
3005 &css_selector,
3006 &key,
3007 text.as_deref(),
3008 )
3009 .await?;
3010 Ok(PluginResult::PressKeyViaCdp(result))
3011 }),
3012 PluginCommand::GetTextContentViaCdp {
3013 cdp_websocket_url,
3014 target_id,
3015 css_selector,
3016 } => block_on_plugin_future(async move {
3017 let result =
3018 get_text_content_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3019 Ok(PluginResult::GetTextContentViaCdp(result))
3020 }),
3021 PluginCommand::GetInnerTextViaCdp {
3022 cdp_websocket_url,
3023 target_id,
3024 css_selector,
3025 } => block_on_plugin_future(async move {
3026 let result =
3027 get_inner_text_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3028 Ok(PluginResult::GetInnerTextViaCdp(result))
3029 }),
3030 PluginCommand::WaitForSelectorViaCdp {
3031 cdp_websocket_url,
3032 target_id,
3033 css_selector,
3034 visible,
3035 } => block_on_plugin_future(async move {
3036 let result =
3037 wait_for_selector_via_cdp(&cdp_websocket_url, &target_id, &css_selector, visible)
3038 .await?;
3039 Ok(PluginResult::WaitForSelectorViaCdp(result))
3040 }),
3041 }
3042}
3043
3044#[unsafe(no_mangle)]
3045pub extern "C" fn allwright_plugin_api_version() -> u32 {
3046 ALLWRIGHT_PLUGIN_API_VERSION
3047}
3048
3049#[unsafe(no_mangle)]
3050pub extern "C" fn allwright_plugin_id() -> *const c_char {
3051 c"web".as_ptr()
3052}
3053
3054#[unsafe(no_mangle)]
3055pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
3056 if request_json.is_null() {
3057 return plugin_response(Err("plugin request pointer is null".to_string()));
3058 }
3059
3060 let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
3061 Ok(request) => request,
3062 Err(error) => {
3063 return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
3064 }
3065 };
3066
3067 let command: PluginCommand = match serde_json::from_str(request) {
3068 Ok(command) => command,
3069 Err(error) => {
3070 return plugin_response(Err(format!("failed to parse plugin request JSON: {error}")));
3071 }
3072 };
3073
3074 plugin_response(handle_plugin_command(command))
3075}
3076
3077#[unsafe(no_mangle)]
3078pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
3079 if !value.is_null() {
3080 unsafe {
3081 let _ = CString::from_raw(value);
3082 }
3083 }
3084}