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