Skip to main content

allwright_surface_web/
lib.rs

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}