Skip to main content

allwright_surface_web/
lib.rs

1use allwright_plugin_sdk::{
2    ALLWRIGHT_PLUGIN_API_VERSION, ChromeLaunchInfo, ChromeTabInfo, ChromiumBidiMapperInfo,
3    ClickInfo, ElementCountInfo, FillInfo, FocusInfo, HighlightElementsInfo, HoverInfo,
4    PluginCommand, PluginEnvelope, PluginResult, PressKeyInfo, SurfaceFamily, SurfacePlugin,
5    SurfacePluginDescriptor, TabNavigationInfo, TextInfo, WaitForSelectorInfo,
6};
7use std::ffi::{CStr, CString, c_char};
8use std::fs;
9use std::future::Future;
10use std::path::PathBuf;
11use std::process::Command;
12use std::thread;
13use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH};
14
15use futures_util::{SinkExt, StreamExt};
16use serde_json::{Value, json};
17use tokio::net::TcpStream;
18use tokio::time::{Duration, sleep};
19use tokio_tungstenite::MaybeTlsStream;
20use tokio_tungstenite::WebSocketStream;
21use tokio_tungstenite::connect_async;
22use tokio_tungstenite::tungstenite::Message;
23
24#[derive(Debug, Clone, PartialEq)]
25struct DiscoveredElements {
26    count: u32,
27    first_center: Option<ElementCenter>,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31struct ElementCenter {
32    x: f64,
33    y: f64,
34}
35
36const CHROMIUM_BIDI_NPM_VERSION: &str = "17.0.2";
37const CHROMIUM_BIDI_MAPPER_BUNDLE: &str =
38    include_str!("../third_party/chromium-bidi/17.0.2/mapperTab.js");
39
40#[derive(Debug, Clone, Copy, Default)]
41pub struct WebPlugin;
42
43impl SurfacePlugin for WebPlugin {
44    fn descriptor(&self) -> SurfacePluginDescriptor {
45        SurfacePluginDescriptor {
46            id: "web",
47            family: SurfaceFamily::Web,
48            version: env!("CARGO_PKG_VERSION"),
49            description: "Web surface plugin for the allwright engine.",
50        }
51    }
52}
53
54pub fn open_chrome_window(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
55    launch_chrome_for_platform(chrome_binary.filter(|value| !value.trim().is_empty()))
56}
57
58pub async fn discover_initial_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
59    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
60
61    for _ in 0..100 {
62        let targets = cdp
63            .send_command("Target.getTargets", json!({}), None)
64            .await?;
65        if let Some(page_target) = targets
66            .get("targetInfos")
67            .and_then(Value::as_array)
68            .and_then(|target_infos| {
69                target_infos.iter().find(|target_info| {
70                    target_info.get("type").and_then(Value::as_str) == Some("page")
71                })
72            })
73        {
74            let target_id = required_string(page_target, "/targetId")?;
75            return Ok(ChromeTabInfo {
76                note: "tracked initial Chrome tab via CDP".to_string(),
77                target_id,
78            });
79        }
80
81        sleep(Duration::from_millis(50)).await;
82    }
83
84    Err("timed out waiting for the initial Chrome page target over CDP".to_string())
85}
86
87pub async fn open_chrome_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
88    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
89    let create_target = cdp
90        .send_command(
91            "Target.createTarget",
92            json!({
93                "url": "about:blank",
94                "background": false,
95            }),
96            None,
97        )
98        .await?;
99    let target_id = required_string(&create_target, "/targetId")?;
100
101    Ok(ChromeTabInfo {
102        note: "opened Chrome tab via CDP".to_string(),
103        target_id,
104    })
105}
106
107pub fn close_browser_process(process_id: u32) -> Result<(), String> {
108    close_browser_process_for_platform(process_id)
109}
110
111pub async fn close_chrome_tab(cdp_websocket_url: &str, target_id: &str) -> Result<(), String> {
112    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
113    let response = cdp
114        .send_command(
115            "Target.closeTarget",
116            json!({
117                "targetId": target_id,
118            }),
119            None,
120        )
121        .await?;
122    let success = response
123        .get("success")
124        .and_then(Value::as_bool)
125        .unwrap_or(false);
126    if success {
127        Ok(())
128    } else {
129        Err(format!(
130            "CDP Target.closeTarget reported unsuccessful close for target {target_id}"
131        ))
132    }
133}
134
135pub async fn navigate_chrome_tab(
136    cdp_websocket_url: &str,
137    target_id: &str,
138    url: &str,
139) -> Result<TabNavigationInfo, String> {
140    if url.trim().is_empty() {
141        return Err("navigate command requires a non-empty url".to_string());
142    }
143
144    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
145    let attach = cdp
146        .send_command(
147            "Target.attachToTarget",
148            json!({
149                "targetId": target_id,
150                "flatten": true,
151            }),
152            None,
153        )
154        .await?;
155    let session_id = required_string(&attach, "/sessionId")?;
156
157    cdp.send_command("Page.enable", json!({}), Some(&session_id))
158        .await?;
159    cdp.navigate_and_wait_for_load(&session_id, url).await?;
160    let frame_tree = cdp
161        .send_command("Page.getFrameTree", json!({}), Some(&session_id))
162        .await?;
163    let browsing_context_id = required_string(&frame_tree, "/frameTree/frame/id")?;
164    cdp.send_command(
165        "Target.detachFromTarget",
166        json!({
167            "sessionId": session_id,
168        }),
169        None,
170    )
171    .await?;
172
173    Ok(TabNavigationInfo {
174        url: url.to_string(),
175        note: "navigated Chrome tab via CDP and observed Page.loadEventFired".to_string(),
176        browsing_context_id,
177    })
178}
179
180pub async fn inject_chromium_bidi_mapper(
181    cdp_websocket_url: &str,
182) -> Result<ChromiumBidiMapperInfo, String> {
183    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
184    let mapper_target = cdp
185        .send_command(
186            "Target.createTarget",
187            json!({
188                "url": "about:blank#MAPPER_TARGET",
189                "hidden": true,
190                "background": true,
191            }),
192            None,
193        )
194        .await?;
195    let mapper_target_id = required_string(&mapper_target, "/targetId")?;
196
197    let attached = cdp
198        .send_command(
199            "Target.attachToTarget",
200            json!({
201                "targetId": mapper_target_id,
202                "flatten": true,
203            }),
204            None,
205        )
206        .await?;
207    let mapper_session_id = required_string(&attached, "/sessionId")?;
208
209    cdp.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
210        .await?;
211    cdp.send_command(
212        "Target.exposeDevToolsProtocol",
213        json!({
214            "bindingName": "cdp",
215            "targetId": mapper_target_id,
216            "inheritPermissions": true,
217        }),
218        None,
219    )
220    .await?;
221    cdp.send_command(
222        "Runtime.addBinding",
223        json!({
224            "name": "sendBidiResponse",
225        }),
226        Some(&mapper_session_id),
227    )
228    .await?;
229    cdp.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
230        .await?;
231    cdp.evaluate_expression(
232        &mapper_session_id,
233        &format!(
234            "window.runMapperInstance('{}')",
235            js_single_quote(&mapper_target_id)
236        ),
237        true,
238    )
239    .await?;
240
241    Ok(ChromiumBidiMapperInfo {
242        package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
243        mapper_target_id,
244        mapper_session_id,
245        note: format!(
246            "injected chromium-bidi mapper from pinned published chromium-bidi@{} artifact into hidden mapper target",
247            CHROMIUM_BIDI_NPM_VERSION
248        ),
249    })
250}
251
252pub async fn click_element_via_cdp(
253    cdp_websocket_url: &str,
254    target_id: &str,
255    css_selector: &str,
256) -> Result<ClickInfo, String> {
257    if css_selector.trim().is_empty() {
258        return Err("click_element command requires a non-empty css_selector".to_string());
259    }
260
261    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
262    let session_id = cdp.prepare_page_target_session(target_id).await?;
263    let discovered =
264        discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
265    let center = discovered.first_center.ok_or_else(|| {
266        format!("element discovery did not return a clickable center for selector {css_selector}")
267    })?;
268    cdp.dispatch_mouse_click(&session_id, center.x, center.y)
269        .await?;
270    cdp.detach_from_target(&session_id).await?;
271
272    Ok(ClickInfo {
273        css_selector: css_selector.to_string(),
274        note: format!("clicked element via CDP mouse events using css selector {css_selector}"),
275    })
276}
277
278pub async fn count_elements_via_cdp(
279    cdp_websocket_url: &str,
280    target_id: &str,
281    css_selector: &str,
282) -> Result<ElementCountInfo, String> {
283    if css_selector.trim().is_empty() {
284        return Err("count_elements command requires a non-empty css_selector".to_string());
285    }
286
287    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
288    let session_id = cdp.prepare_page_target_session(target_id).await?;
289    let discovered =
290        discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
291    cdp.detach_from_target(&session_id).await?;
292
293    Ok(ElementCountInfo {
294        css_selector: css_selector.to_string(),
295        count: discovered.count,
296        note: format!(
297            "counted {} element(s) matching css selector {css_selector}",
298            discovered.count
299        ),
300    })
301}
302
303pub async fn highlight_elements_via_cdp(
304    cdp_websocket_url: &str,
305    target_id: &str,
306    css_selector: &str,
307    duration_ms: u32,
308) -> Result<HighlightElementsInfo, String> {
309    if css_selector.trim().is_empty() {
310        return Err("highlight_elements command requires a non-empty css_selector".to_string());
311    }
312
313    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
314    let session_id = cdp.prepare_page_target_session(target_id).await?;
315    let discovered =
316        discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
317
318    let selector_literal = serde_json::to_string(css_selector)
319        .map_err(|error| format!("failed to serialize css selector for highlight: {error}"))?;
320    let duration_ms = duration_ms.max(1);
321    cdp.evaluate_expression(
322        &session_id,
323        &format!(
324            "(() => {{
325                const selector = {selector_literal};
326                const durationMs = {duration_ms};
327                const elements = Array.from(document.querySelectorAll(selector));
328                for (const element of elements) {{
329                    if (!(element instanceof HTMLElement)) {{
330                        continue;
331                    }}
332                    element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
333                    const priorOutline = element.style.outline;
334                    const priorOutlineOffset = element.style.outlineOffset;
335                    const priorBackgroundColor = element.style.backgroundColor;
336                    element.style.outline = '3px solid #ff5a36';
337                    element.style.outlineOffset = '2px';
338                    element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
339                    window.setTimeout(() => {{
340                        element.style.outline = priorOutline;
341                        element.style.outlineOffset = priorOutlineOffset;
342                        element.style.backgroundColor = priorBackgroundColor;
343                    }}, durationMs);
344                }}
345                return elements.length;
346            }})()"
347        ),
348        true,
349    )
350    .await?;
351    cdp.detach_from_target(&session_id).await?;
352
353    Ok(HighlightElementsInfo {
354        css_selector: css_selector.to_string(),
355        count: discovered.count,
356        note: format!(
357            "highlighted {} element(s) matching css selector {css_selector} for {duration_ms}ms",
358            discovered.count
359        ),
360    })
361}
362
363pub async fn focus_element_via_cdp(
364    cdp_websocket_url: &str,
365    target_id: &str,
366    css_selector: &str,
367) -> Result<FocusInfo, String> {
368    require_non_empty_selector("focus_element", css_selector)?;
369    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
370    let session_id = cdp.prepare_page_target_session(target_id).await?;
371    discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
372    cdp.detach_from_target(&session_id).await?;
373
374    Ok(FocusInfo {
375        css_selector: css_selector.to_string(),
376        note: format!("focused element matching css selector {css_selector}"),
377    })
378}
379
380pub async fn fill_element_via_cdp(
381    cdp_websocket_url: &str,
382    target_id: &str,
383    css_selector: &str,
384    value: &str,
385) -> Result<FillInfo, String> {
386    require_non_empty_selector("fill_element", css_selector)?;
387    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
388    let session_id = cdp.prepare_page_target_session(target_id).await?;
389    discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
390    let selector_literal = json_string_literal(css_selector, "fill selector")?;
391    let value_literal = json_string_literal(value, "fill value")?;
392    cdp.evaluate_expression(
393        &session_id,
394        &format!(
395            "(() => {{
396                const selector = {selector_literal};
397                const value = {value_literal};
398                const element = document.querySelector(selector);
399                if (!element) {{
400                    throw new Error(`No element matches selector: ${{selector}}`);
401                }}
402                if (!(element instanceof HTMLElement)) {{
403                    throw new Error(`Element is not focusable: ${{selector}}`);
404                }}
405                element.focus();
406                if ('value' in element) {{
407                    element.value = value;
408                }} else if (element.isContentEditable) {{
409                    element.textContent = value;
410                }} else {{
411                    throw new Error(`Element does not support fill: ${{selector}}`);
412                }}
413                element.dispatchEvent(new Event('input', {{ bubbles: true }}));
414                element.dispatchEvent(new Event('change', {{ bubbles: true }}));
415                return true;
416            }})()"
417        ),
418        true,
419    )
420    .await?;
421    cdp.detach_from_target(&session_id).await?;
422
423    Ok(FillInfo {
424        css_selector: css_selector.to_string(),
425        value: value.to_string(),
426        note: format!("filled element matching css selector {css_selector}"),
427    })
428}
429
430pub async fn hover_element_via_cdp(
431    cdp_websocket_url: &str,
432    target_id: &str,
433    css_selector: &str,
434) -> Result<HoverInfo, String> {
435    require_non_empty_selector("hover_element", css_selector)?;
436    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
437    let session_id = cdp.prepare_page_target_session(target_id).await?;
438    let discovered =
439        discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
440    let center = discovered.first_center.ok_or_else(|| {
441        format!("element discovery did not return a hover center for selector {css_selector}")
442    })?;
443    cdp.dispatch_mouse_move(&session_id, center.x, center.y)
444        .await?;
445    cdp.detach_from_target(&session_id).await?;
446
447    Ok(HoverInfo {
448        css_selector: css_selector.to_string(),
449        note: format!("hovered element matching css selector {css_selector}"),
450    })
451}
452
453pub async fn press_key_via_cdp(
454    cdp_websocket_url: &str,
455    target_id: &str,
456    css_selector: &str,
457    key: &str,
458    text: Option<&str>,
459) -> Result<PressKeyInfo, String> {
460    require_non_empty_selector("press_key", css_selector)?;
461    if key.trim().is_empty() {
462        return Err("press_key command requires a non-empty key".to_string());
463    }
464
465    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
466    let session_id = cdp.prepare_page_target_session(target_id).await?;
467    discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
468    cdp.dispatch_key_press(&session_id, key, text).await?;
469    cdp.detach_from_target(&session_id).await?;
470
471    Ok(PressKeyInfo {
472        css_selector: css_selector.to_string(),
473        key: key.to_string(),
474        note: format!("pressed key {key} on element matching css selector {css_selector}"),
475    })
476}
477
478pub async fn get_text_content_via_cdp(
479    cdp_websocket_url: &str,
480    target_id: &str,
481    css_selector: &str,
482) -> Result<TextInfo, String> {
483    get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "textContent")
484        .await
485        .map(|mut info| {
486            info.note = format!("resolved textContent for css selector {css_selector}");
487            info
488        })
489}
490
491pub async fn get_inner_text_via_cdp(
492    cdp_websocket_url: &str,
493    target_id: &str,
494    css_selector: &str,
495) -> Result<TextInfo, String> {
496    get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "innerText")
497        .await
498        .map(|mut info| {
499            info.note = format!("resolved innerText for css selector {css_selector}");
500            info
501        })
502}
503
504pub async fn wait_for_selector_via_cdp(
505    cdp_websocket_url: &str,
506    target_id: &str,
507    css_selector: &str,
508    visible: bool,
509) -> Result<WaitForSelectorInfo, String> {
510    require_non_empty_selector("wait_for_selector", css_selector)?;
511    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
512    let session_id = cdp.prepare_page_target_session(target_id).await?;
513    let discovered =
514        discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
515    let success = if visible {
516        discovered.first_center.is_some()
517    } else {
518        discovered.count > 0
519    };
520    cdp.detach_from_target(&session_id).await?;
521
522    if !success {
523        return Err(if visible {
524            format!("no visible element matches css selector {css_selector}")
525        } else {
526            format!("no element matches css selector {css_selector}")
527        });
528    }
529
530    Ok(WaitForSelectorInfo {
531        css_selector: css_selector.to_string(),
532        visible,
533        note: if visible {
534            format!("visible element matched css selector {css_selector}")
535        } else {
536            format!("element matched css selector {css_selector}")
537        },
538    })
539}
540
541async fn discover_elements_via_cdp(
542    cdp: &mut CdpConnection,
543    session_id: &str,
544    css_selector: &str,
545    require_match: bool,
546    focus_first_match: bool,
547) -> Result<DiscoveredElements, String> {
548    let selector_literal = serde_json::to_string(css_selector)
549        .map_err(|error| format!("failed to serialize css selector for discovery: {error}"))?;
550    let expression = format!(
551        "(() => {{
552            const selector = {selector_literal};
553            const elements = Array.from(document.querySelectorAll(selector));
554            const first = elements[0] ?? null;
555            if (first) {{
556                first.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
557                if ({focus_first_match} && first instanceof HTMLElement) {{
558                    first.focus();
559                }}
560            }}
561            if ({require_match} && !first) {{
562                throw new Error(`No element matches selector: ${{selector}}`);
563            }}
564            if (first && !(first instanceof Element)) {{
565                throw new Error(`Selector did not resolve to a DOM Element: ${{selector}}`);
566            }}
567            const rect = first ? first.getBoundingClientRect() : null;
568            if (first && (!rect.width || !rect.height)) {{
569                throw new Error(`Element matched by selector has zero size: ${{selector}}`);
570            }}
571            return {{
572                count: elements.length,
573                first: rect ? {{
574                    x: rect.left + (rect.width / 2),
575                    y: rect.top + (rect.height / 2)
576                }} : null
577            }};
578        }})()"
579    );
580    let result = cdp
581        .evaluate_expression(session_id, &expression, true)
582        .await?;
583    let count = json_u32(&result, "/result/value/count")?;
584    let first_center = if result.pointer("/result/value/first").is_some()
585        && !result
586            .pointer("/result/value/first")
587            .is_some_and(Value::is_null)
588    {
589        Some(ElementCenter {
590            x: json_f64(&result, "/result/value/first/x")?,
591            y: json_f64(&result, "/result/value/first/y")?,
592        })
593    } else {
594        None
595    };
596
597    Ok(DiscoveredElements {
598        count,
599        first_center,
600    })
601}
602
603async fn get_text_via_cdp(
604    cdp_websocket_url: &str,
605    target_id: &str,
606    css_selector: &str,
607    property: &str,
608) -> Result<TextInfo, String> {
609    require_non_empty_selector("get_text", css_selector)?;
610    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
611    let session_id = cdp.prepare_page_target_session(target_id).await?;
612    discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
613    let selector_literal = json_string_literal(css_selector, "text selector")?;
614    let property_literal = json_string_literal(property, "text property")?;
615    let result = cdp
616        .evaluate_expression(
617            &session_id,
618            &format!(
619                "(() => {{
620                    const selector = {selector_literal};
621                    const property = {property_literal};
622                    const element = document.querySelector(selector);
623                    if (!element) {{
624                        throw new Error(`No element matches selector: ${{selector}}`);
625                    }}
626                    const value = element[property];
627                    return typeof value === 'string' ? value : '';
628                }})()"
629            ),
630            true,
631        )
632        .await?;
633    cdp.detach_from_target(&session_id).await?;
634
635    Ok(TextInfo {
636        css_selector: css_selector.to_string(),
637        text: json_string(&result, "/result/value")?,
638        note: String::new(),
639    })
640}
641
642pub async fn resolve_bidi_context_for_tab(
643    cdp_websocket_url: &str,
644    existing_mapper_target_id: Option<&str>,
645    existing_context_id: Option<&str>,
646    current_url: Option<&str>,
647) -> Result<(String, ChromiumBidiMapperInfo), String> {
648    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
649    let mapper = cdp
650        .ensure_chromium_bidi_mapper(existing_mapper_target_id)
651        .await?;
652    let context_id = cdp
653        .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, current_url)
654        .await?;
655
656    Ok((
657        context_id,
658        ChromiumBidiMapperInfo {
659            package_version: mapper.package_version,
660            mapper_target_id: mapper.mapper_target_id,
661            mapper_session_id: mapper.mapper_session_id,
662            note: "resolved BiDi browsing context for tab".to_string(),
663        },
664    ))
665}
666
667#[cfg(target_os = "macos")]
668fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
669    let browser_binary = chrome_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
670        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string()
671    });
672    launch_chrome_with_cdp(&browser_binary)
673}
674
675#[cfg(target_os = "linux")]
676fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
677    if let Some(chrome_binary) = chrome_binary {
678        return launch_chrome_with_cdp(chrome_binary);
679    }
680
681    let candidates = ["google-chrome", "chromium", "chromium-browser"];
682
683    for candidate in candidates {
684        match launch_chrome_with_cdp(candidate) {
685            Ok(launch_info) => return Ok(launch_info),
686            Err(error) => {
687                if error.contains("No such file or directory")
688                    || error.contains("os error 2")
689                    || error.contains("not found")
690                {
691                    continue;
692                }
693                return Err(format!("failed to launch {candidate}: {error}"));
694            }
695        }
696    }
697
698    Err("could not find a Chrome-compatible browser binary".to_string())
699}
700
701#[cfg(target_os = "windows")]
702fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
703    if let Some(chrome_binary) = chrome_binary {
704        return launch_chrome_with_cdp(chrome_binary);
705    }
706
707    launch_chrome_with_cdp("chrome")
708}
709
710#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
711fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
712    match chrome_binary {
713        Some(chrome_binary) => launch_chrome_with_cdp(chrome_binary),
714        None => Err("Chrome launching is not supported on this platform yet".to_string()),
715    }
716}
717
718fn launch_chrome_with_cdp(browser_binary: &str) -> Result<ChromeLaunchInfo, String> {
719    let user_data_dir = create_chrome_user_data_dir()?;
720    let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
721
722    let child = Command::new(browser_binary)
723        .args([
724            "--new-window",
725            "--remote-debugging-port=0",
726            "--no-first-run",
727            "--no-default-browser-check",
728            "--disable-sync",
729            &format!("--user-data-dir={user_data_dir_str}"),
730        ])
731        .spawn()
732        .map_err(|error| format!("failed to launch Chrome binary {browser_binary}: {error}"))?;
733    let process_id = child.id();
734
735    let cdp_websocket_url = wait_for_cdp_endpoint(&user_data_dir)?;
736
737    Ok(ChromeLaunchInfo {
738        browser: browser_binary.to_string(),
739        note: "launched Chrome window with CDP enabled".to_string(),
740        cdp_websocket_url,
741        user_data_dir: user_data_dir_str,
742        process_id,
743    })
744}
745
746fn create_chrome_user_data_dir() -> Result<PathBuf, String> {
747    let timestamp = SystemTime::now()
748        .duration_since(UNIX_EPOCH)
749        .map_err(|error| format!("failed to compute timestamp: {error}"))?
750        .as_millis();
751    let pid = std::process::id();
752    let dir = std::env::temp_dir().join(format!("allwright-chrome-{pid}-{timestamp}"));
753    fs::create_dir_all(&dir).map_err(|error| {
754        format!(
755            "failed to create Chrome user data dir {}: {error}",
756            dir.display()
757        )
758    })?;
759    Ok(dir)
760}
761
762fn wait_for_cdp_endpoint(user_data_dir: &std::path::Path) -> Result<String, String> {
763    let active_port_file = user_data_dir.join("DevToolsActivePort");
764
765    for _ in 0..100 {
766        if active_port_file.exists() {
767            let contents = fs::read_to_string(&active_port_file).map_err(|error| {
768                format!(
769                    "failed to read DevToolsActivePort file {}: {error}",
770                    active_port_file.display()
771                )
772            })?;
773            return parse_devtools_active_port(&contents);
774        }
775        thread::sleep(StdDuration::from_millis(50));
776    }
777
778    Err(format!(
779        "timed out waiting for DevToolsActivePort in {}",
780        user_data_dir.display()
781    ))
782}
783
784fn parse_devtools_active_port(contents: &str) -> Result<String, String> {
785    let mut lines = contents.lines();
786    let port = lines
787        .next()
788        .map(str::trim)
789        .filter(|value| !value.is_empty())
790        .ok_or_else(|| "DevToolsActivePort missing port line".to_string())?;
791    let browser_path = lines
792        .next()
793        .map(str::trim)
794        .filter(|value| !value.is_empty())
795        .ok_or_else(|| "DevToolsActivePort missing websocket path line".to_string())?;
796
797    Ok(format!("ws://127.0.0.1:{port}{browser_path}"))
798}
799
800pub async fn boot() -> String {
801    sleep(Duration::from_millis(15)).await;
802    "web ready".to_string()
803}
804
805#[cfg(unix)]
806fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
807    let status = Command::new("kill")
808        .args(["-TERM", &process_id.to_string()])
809        .status()
810        .map_err(|error| format!("failed to send TERM to process {process_id}: {error}"))?;
811
812    if status.success() {
813        Ok(())
814    } else {
815        Err(format!(
816            "kill -TERM returned non-zero exit status for process {process_id}: {status}"
817        ))
818    }
819}
820
821#[cfg(windows)]
822fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
823    let status = Command::new("taskkill")
824        .args(["/PID", &process_id.to_string(), "/T", "/F"])
825        .status()
826        .map_err(|error| format!("failed to terminate process {process_id}: {error}"))?;
827
828    if status.success() {
829        Ok(())
830    } else {
831        Err(format!(
832            "taskkill returned non-zero exit status for process {process_id}: {status}"
833        ))
834    }
835}
836
837#[cfg(not(any(unix, windows)))]
838fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
839    Err(format!(
840        "browser process termination is not implemented for process {process_id} on this platform"
841    ))
842}
843
844type CdpSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
845
846struct MapperConnectionInfo {
847    package_version: String,
848    mapper_target_id: String,
849    mapper_session_id: String,
850}
851
852struct CdpConnection {
853    socket: CdpSocket,
854    next_id: u64,
855}
856
857impl CdpConnection {
858    async fn connect(cdp_websocket_url: &str) -> Result<Self, String> {
859        let (socket, _) = connect_async(cdp_websocket_url).await.map_err(|error| {
860            format!("failed to connect to CDP websocket {cdp_websocket_url}: {error}")
861        })?;
862        Ok(Self { socket, next_id: 1 })
863    }
864
865    async fn send_command(
866        &mut self,
867        method: &str,
868        params: Value,
869        session_id: Option<&str>,
870    ) -> Result<Value, String> {
871        let id = self.next_id;
872        self.next_id += 1;
873
874        let mut payload = json!({
875            "id": id,
876            "method": method,
877            "params": params,
878        });
879        if let Some(session_id) = session_id {
880            payload["sessionId"] = Value::String(session_id.to_string());
881        }
882
883        self.socket
884            .send(Message::Text(payload.to_string().into()))
885            .await
886            .map_err(|error| format!("failed to send CDP command {method}: {error}"))?;
887
888        loop {
889            let message = self.next_json_message().await?;
890            if message.get("id").and_then(Value::as_u64) != Some(id) {
891                continue;
892            }
893
894            if let Some(error) = message.get("error") {
895                return Err(format!("CDP command {method} failed: {error}"));
896            }
897
898            return Ok(message.get("result").cloned().unwrap_or(Value::Null));
899        }
900    }
901
902    async fn evaluate_expression(
903        &mut self,
904        session_id: &str,
905        expression: &str,
906        await_promise: bool,
907    ) -> Result<Value, String> {
908        let result = self
909            .send_command(
910                "Runtime.evaluate",
911                json!({
912                    "expression": expression,
913                    "awaitPromise": await_promise,
914                    "returnByValue": true,
915                }),
916                Some(session_id),
917            )
918            .await?;
919        if let Some(exception_details) = result.get("exceptionDetails") {
920            return Err(format!(
921                "Runtime.evaluate failed with exception details: {exception_details}"
922            ));
923        }
924        Ok(result)
925    }
926
927    async fn attach_to_target(&mut self, target_id: &str) -> Result<String, String> {
928        let attached = self
929            .send_command(
930                "Target.attachToTarget",
931                json!({
932                    "targetId": target_id,
933                    "flatten": true,
934                }),
935                None,
936            )
937            .await?;
938        required_string(&attached, "/sessionId")
939    }
940
941    async fn prepare_page_target_session(&mut self, target_id: &str) -> Result<String, String> {
942        let session_id = self.attach_to_target(target_id).await?;
943        self.send_command("Runtime.enable", json!({}), Some(&session_id))
944            .await?;
945        self.send_command("Page.enable", json!({}), Some(&session_id))
946            .await?;
947        Ok(session_id)
948    }
949
950    async fn detach_from_target(&mut self, session_id: &str) -> Result<(), String> {
951        self.send_command(
952            "Target.detachFromTarget",
953            json!({
954                "sessionId": session_id,
955            }),
956            None,
957        )
958        .await?;
959        Ok(())
960    }
961
962    async fn dispatch_mouse_click(
963        &mut self,
964        session_id: &str,
965        x: f64,
966        y: f64,
967    ) -> Result<(), String> {
968        self.dispatch_mouse_move(session_id, x, y).await?;
969        self.send_command(
970            "Input.dispatchMouseEvent",
971            json!({
972                "type": "mousePressed",
973                "x": x,
974                "y": y,
975                "button": "left",
976                "buttons": 1,
977                "clickCount": 1,
978            }),
979            Some(session_id),
980        )
981        .await?;
982        self.send_command(
983            "Input.dispatchMouseEvent",
984            json!({
985                "type": "mouseReleased",
986                "x": x,
987                "y": y,
988                "button": "left",
989                "buttons": 0,
990                "clickCount": 1,
991            }),
992            Some(session_id),
993        )
994        .await?;
995        Ok(())
996    }
997
998    async fn dispatch_mouse_move(
999        &mut self,
1000        session_id: &str,
1001        x: f64,
1002        y: f64,
1003    ) -> Result<(), String> {
1004        self.send_command(
1005            "Input.dispatchMouseEvent",
1006            json!({
1007                "type": "mouseMoved",
1008                "x": x,
1009                "y": y,
1010                "button": "none",
1011                "buttons": 0,
1012            }),
1013            Some(session_id),
1014        )
1015        .await?;
1016        Ok(())
1017    }
1018
1019    async fn dispatch_key_press(
1020        &mut self,
1021        session_id: &str,
1022        key: &str,
1023        text: Option<&str>,
1024    ) -> Result<(), String> {
1025        self.send_command(
1026            "Input.dispatchKeyEvent",
1027            json!({
1028                "type": "keyDown",
1029                "key": key,
1030                "text": text.unwrap_or(""),
1031            }),
1032            Some(session_id),
1033        )
1034        .await?;
1035        self.send_command(
1036            "Input.dispatchKeyEvent",
1037            json!({
1038                "type": "keyUp",
1039                "key": key,
1040                "text": text.unwrap_or(""),
1041            }),
1042            Some(session_id),
1043        )
1044        .await?;
1045        Ok(())
1046    }
1047
1048    async fn ensure_chromium_bidi_mapper(
1049        &mut self,
1050        existing_mapper_target_id: Option<&str>,
1051    ) -> Result<MapperConnectionInfo, String> {
1052        let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
1053            Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
1054                (existing_mapper_target_id.to_string(), false)
1055            }
1056            _ => (self.create_bidi_mapper_target().await?, true),
1057        };
1058
1059        let attached = match self
1060            .send_command(
1061                "Target.attachToTarget",
1062                json!({
1063                    "targetId": mapper_target_id,
1064                    "flatten": true,
1065                }),
1066                None,
1067            )
1068            .await
1069        {
1070            Ok(attached) => attached,
1071            Err(error)
1072                if !created_target
1073                    && error.contains("Target.attachToTarget failed")
1074                    && error.contains("No target with given id found") =>
1075            {
1076                mapper_target_id = self.create_bidi_mapper_target().await?;
1077                created_target = true;
1078                self.send_command(
1079                    "Target.attachToTarget",
1080                    json!({
1081                        "targetId": mapper_target_id,
1082                        "flatten": true,
1083                    }),
1084                    None,
1085                )
1086                .await?
1087            }
1088            Err(error) => return Err(error),
1089        };
1090        let mapper_session_id = required_string(&attached, "/sessionId")?;
1091
1092        self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
1093            .await?;
1094        self.send_command(
1095            "Target.exposeDevToolsProtocol",
1096            json!({
1097                "bindingName": "cdp",
1098                "targetId": mapper_target_id,
1099                "inheritPermissions": true,
1100            }),
1101            None,
1102        )
1103        .await?;
1104        self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
1105            .await?;
1106
1107        if created_target
1108            || !self
1109                .mapper_runtime_is_initialized(&mapper_session_id)
1110                .await?
1111        {
1112            self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
1113                .await?;
1114            self.evaluate_expression(
1115                &mapper_session_id,
1116                &format!(
1117                    "window.runMapperInstance('{}')",
1118                    js_single_quote(&mapper_target_id)
1119                ),
1120                true,
1121            )
1122            .await?;
1123            self.send_bidi_command(
1124                &mapper_session_id,
1125                &json!({
1126                    "id": 1,
1127                    "method": "session.new",
1128                    "params": {
1129                        "capabilities": {}
1130                    }
1131                }),
1132            )
1133            .await?;
1134        }
1135
1136        Ok(MapperConnectionInfo {
1137            package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
1138            mapper_target_id,
1139            mapper_session_id,
1140        })
1141    }
1142
1143    async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
1144        let created = self
1145            .send_command(
1146                "Target.createTarget",
1147                json!({
1148                    "url": "about:blank#MAPPER_TARGET",
1149                    "hidden": true,
1150                    "background": true,
1151                }),
1152                None,
1153            )
1154            .await?;
1155        required_string(&created, "/targetId")
1156    }
1157
1158    async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
1159        match self
1160            .send_command(
1161                "Runtime.addBinding",
1162                json!({
1163                    "name": name,
1164                }),
1165                Some(session_id),
1166            )
1167            .await
1168        {
1169            Ok(_) => Ok(()),
1170            Err(error)
1171                if error.contains("already exists") || error.contains("Cannot add binding") =>
1172            {
1173                Ok(())
1174            }
1175            Err(error) => Err(error),
1176        }
1177    }
1178
1179    async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
1180        let result = self
1181            .evaluate_expression(
1182                session_id,
1183                "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
1184                true,
1185            )
1186            .await?;
1187        Ok(result
1188            .pointer("/result/value")
1189            .and_then(Value::as_bool)
1190            .unwrap_or(false))
1191    }
1192
1193    async fn send_bidi_command(
1194        &mut self,
1195        mapper_session_id: &str,
1196        command: &Value,
1197    ) -> Result<Value, String> {
1198        let command_id = command
1199            .get("id")
1200            .and_then(Value::as_u64)
1201            .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
1202        let eval_id = self.next_id;
1203        self.next_id += 1;
1204        let command_json = command.to_string();
1205        let expression = format!(
1206            "onBidiMessage({})",
1207            serde_json::to_string(&command_json)
1208                .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
1209        );
1210
1211        let payload = json!({
1212            "id": eval_id,
1213            "method": "Runtime.evaluate",
1214            "params": {
1215                "expression": expression,
1216                "awaitPromise": true,
1217            },
1218            "sessionId": mapper_session_id,
1219        });
1220        self.socket
1221            .send(Message::Text(payload.to_string().into()))
1222            .await
1223            .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
1224
1225        loop {
1226            let message = self.next_json_message().await?;
1227
1228            if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
1229                if let Some(error) = message.get("error") {
1230                    return Err(format!("mapper Runtime.evaluate failed: {error}"));
1231                }
1232                if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
1233                    return Err(format!(
1234                        "mapper Runtime.evaluate raised exception details: {exception_details}"
1235                    ));
1236                }
1237                continue;
1238            }
1239
1240            if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
1241                continue;
1242            }
1243
1244            if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
1245                continue;
1246            }
1247
1248            let params = message
1249                .get("params")
1250                .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
1251            if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
1252                continue;
1253            }
1254
1255            let payload = params
1256                .get("payload")
1257                .and_then(Value::as_str)
1258                .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
1259            let response = serde_json::from_str::<Value>(payload)
1260                .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
1261            if response.get("id").and_then(Value::as_u64) != Some(command_id) {
1262                continue;
1263            }
1264
1265            match response.get("type").and_then(Value::as_str) {
1266                Some("success") => return Ok(response),
1267                Some("error") => {
1268                    let message = response
1269                        .get("message")
1270                        .and_then(Value::as_str)
1271                        .unwrap_or("unknown BiDi mapper error");
1272                    let error = response
1273                        .get("error")
1274                        .and_then(Value::as_str)
1275                        .unwrap_or("unknown error");
1276                    return Err(format!("BiDi mapper returned {error}: {message}"));
1277                }
1278                Some(other) => {
1279                    return Err(format!("unexpected BiDi response type {other}: {response}"));
1280                }
1281                None => {
1282                    return Err(format!("BiDi mapper response is missing type: {response}"));
1283                }
1284            }
1285        }
1286    }
1287
1288    async fn resolve_bidi_context_id(
1289        &mut self,
1290        mapper_session_id: &str,
1291        existing_context_id: Option<&str>,
1292        current_url: Option<&str>,
1293    ) -> Result<String, String> {
1294        if let Some(existing_context_id) = existing_context_id {
1295            if !existing_context_id.trim().is_empty() {
1296                let probe = json!({
1297                    "id": 1,
1298                    "method": "browsingContext.getTree",
1299                    "params": {
1300                        "root": existing_context_id,
1301                        "maxDepth": 0,
1302                    }
1303                });
1304                match self.send_bidi_command(mapper_session_id, &probe).await {
1305                    Ok(_) => return Ok(existing_context_id.to_string()),
1306                    Err(error)
1307                        if error.contains("no such frame")
1308                            || error.contains("Context ")
1309                            || error.contains("invalid argument") => {}
1310                    Err(error) => return Err(error),
1311                }
1312            }
1313        }
1314
1315        let target_url = current_url
1316            .map(str::trim)
1317            .filter(|value| !value.is_empty())
1318            .map(str::to_string);
1319
1320        for attempt in 0..20 {
1321            let tree = self
1322                .send_bidi_command(
1323                    mapper_session_id,
1324                    &json!({
1325                        "id": 2,
1326                        "method": "browsingContext.getTree",
1327                        "params": {
1328                            "maxDepth": 0,
1329                        }
1330                    }),
1331                )
1332                .await?;
1333            let contexts = tree
1334                .pointer("/result/contexts")
1335                .and_then(Value::as_array)
1336                .ok_or_else(|| {
1337                    format!("BiDi browsingContext.getTree returned no contexts: {tree}")
1338                })?;
1339
1340            if let Some(target_url) = target_url.as_deref() {
1341                if let Some(context_id) = contexts.iter().find_map(|context| {
1342                    let url = context.get("url").and_then(Value::as_str)?;
1343                    if url == target_url {
1344                        context
1345                            .get("context")
1346                            .and_then(Value::as_str)
1347                            .map(str::to_string)
1348                    } else {
1349                        None
1350                    }
1351                }) {
1352                    return Ok(context_id);
1353                }
1354            }
1355
1356            if let Some(context_id) = contexts
1357                .first()
1358                .and_then(|context| context.get("context"))
1359                .and_then(Value::as_str)
1360                .filter(|value| !value.trim().is_empty())
1361                .map(str::to_string)
1362            {
1363                return Ok(context_id);
1364            }
1365
1366            if attempt < 19 {
1367                sleep(Duration::from_millis(150)).await;
1368            } else {
1369                return Err(format!(
1370                    "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
1371                ));
1372            }
1373        }
1374
1375        Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
1376    }
1377
1378    async fn navigate_and_wait_for_load(
1379        &mut self,
1380        session_id: &str,
1381        url: &str,
1382    ) -> Result<(), String> {
1383        let id = self.next_id;
1384        self.next_id += 1;
1385
1386        let payload = json!({
1387            "id": id,
1388            "method": "Page.navigate",
1389            "params": {
1390                "url": url,
1391            },
1392            "sessionId": session_id,
1393        });
1394        self.socket
1395            .send(Message::Text(payload.to_string().into()))
1396            .await
1397            .map_err(|error| format!("failed to send CDP command Page.navigate: {error}"))?;
1398
1399        let mut navigate_confirmed = false;
1400        let mut load_seen = false;
1401
1402        loop {
1403            let message = self.next_json_message().await?;
1404
1405            if message.get("id").and_then(Value::as_u64) == Some(id) {
1406                if let Some(error) = message.get("error") {
1407                    return Err(format!("CDP command Page.navigate failed: {error}"));
1408                }
1409                navigate_confirmed = true;
1410            } else if message.get("sessionId").and_then(Value::as_str) == Some(session_id)
1411                && message.get("method").and_then(Value::as_str) == Some("Page.loadEventFired")
1412            {
1413                load_seen = true;
1414            }
1415
1416            if navigate_confirmed && load_seen {
1417                return Ok(());
1418            }
1419        }
1420    }
1421
1422    async fn next_json_message(&mut self) -> Result<Value, String> {
1423        loop {
1424            let message = self
1425                .socket
1426                .next()
1427                .await
1428                .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
1429                .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
1430
1431            match message {
1432                Message::Text(text) => {
1433                    let value = serde_json::from_str::<Value>(&text)
1434                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
1435                    return Ok(value);
1436                }
1437                Message::Binary(bytes) => {
1438                    let value = serde_json::from_slice::<Value>(&bytes)
1439                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
1440                    return Ok(value);
1441                }
1442                Message::Ping(payload) => {
1443                    self.socket
1444                        .send(Message::Pong(payload))
1445                        .await
1446                        .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
1447                }
1448                Message::Pong(_) => {}
1449                Message::Frame(_) => {}
1450                Message::Close(frame) => {
1451                    return Err(format!("CDP websocket closed: {frame:?}"));
1452                }
1453            }
1454        }
1455    }
1456}
1457
1458fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
1459    value
1460        .pointer(pointer)
1461        .and_then(Value::as_str)
1462        .map(str::to_string)
1463        .filter(|value| !value.is_empty())
1464        .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
1465}
1466
1467fn js_single_quote(value: &str) -> String {
1468    value.replace('\\', "\\\\").replace('\'', "\\'")
1469}
1470
1471fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
1472    let raw = value
1473        .pointer(pointer)
1474        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
1475
1476    if let Some(number) = raw.as_u64() {
1477        return u32::try_from(number)
1478            .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
1479    }
1480
1481    if let Some(number) = raw.as_i64() {
1482        return u32::try_from(number).map_err(|_| {
1483            format!("numeric JSON value at {pointer} is out of range for u32: {number}")
1484        });
1485    }
1486
1487    Err(format!(
1488        "expected numeric JSON value at {pointer}, found {raw}"
1489    ))
1490}
1491
1492fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
1493    let raw = value
1494        .pointer(pointer)
1495        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
1496
1497    raw.as_f64()
1498        .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
1499}
1500
1501fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
1502    value
1503        .pointer(pointer)
1504        .and_then(Value::as_str)
1505        .map(str::to_string)
1506        .ok_or_else(|| format!("expected string JSON value at {pointer}"))
1507}
1508
1509fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
1510    serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
1511}
1512
1513fn require_non_empty_selector(command_name: &str, css_selector: &str) -> Result<(), String> {
1514    if css_selector.trim().is_empty() {
1515        Err(format!(
1516            "{command_name} command requires a non-empty css_selector"
1517        ))
1518    } else {
1519        Ok(())
1520    }
1521}
1522
1523fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
1524where
1525    F: Future<Output = Result<T, String>>,
1526{
1527    let runtime = tokio::runtime::Builder::new_current_thread()
1528        .enable_all()
1529        .build()
1530        .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
1531    runtime.block_on(future)
1532}
1533
1534fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
1535    let envelope = match result {
1536        Ok(result) => PluginEnvelope {
1537            ok: true,
1538            result: Some(result),
1539            error: None,
1540        },
1541        Err(error) => PluginEnvelope {
1542            ok: false,
1543            result: None,
1544            error: Some(error),
1545        },
1546    };
1547
1548    let json = match serde_json::to_string(&envelope) {
1549        Ok(json) => json,
1550        Err(error) => {
1551            let fallback = PluginEnvelope {
1552                ok: false,
1553                result: None,
1554                error: Some(format!("failed to serialize plugin response: {error}")),
1555            };
1556            serde_json::to_string(&fallback).unwrap_or_else(|_| {
1557                "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
1558                    .to_string()
1559            })
1560        }
1561    };
1562
1563    CString::new(json).unwrap().into_raw()
1564}
1565
1566fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
1567    match command {
1568        PluginCommand::OpenChromeWindow { chrome_binary } => {
1569            open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
1570        }
1571        PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
1572            block_on_plugin_future(async move {
1573                let result = discover_initial_tab(&cdp_websocket_url).await?;
1574                Ok(PluginResult::DiscoverInitialTab(result))
1575            })
1576        }
1577        PluginCommand::OpenChromeTab { cdp_websocket_url } => {
1578            block_on_plugin_future(async move {
1579                let result = open_chrome_tab(&cdp_websocket_url).await?;
1580                Ok(PluginResult::OpenChromeTab(result))
1581            })
1582        }
1583        PluginCommand::CloseBrowserProcess { process_id } => {
1584            close_browser_process(process_id)?;
1585            Ok(PluginResult::CloseBrowserProcess)
1586        }
1587        PluginCommand::CloseChromeTab {
1588            cdp_websocket_url,
1589            target_id,
1590        } => block_on_plugin_future(async move {
1591            close_chrome_tab(&cdp_websocket_url, &target_id).await?;
1592            Ok(PluginResult::CloseChromeTab)
1593        }),
1594        PluginCommand::NavigateChromeTab {
1595            cdp_websocket_url,
1596            target_id,
1597            url,
1598        } => block_on_plugin_future(async move {
1599            let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
1600            Ok(PluginResult::NavigateChromeTab(result))
1601        }),
1602        PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
1603            block_on_plugin_future(async move {
1604                let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
1605                Ok(PluginResult::InjectChromiumBidiMapper(result))
1606            })
1607        }
1608        PluginCommand::ResolveBidiContextForTab {
1609            cdp_websocket_url,
1610            mapper_target_id,
1611            browsing_context_id,
1612            url,
1613        } => block_on_plugin_future(async move {
1614            let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
1615                &cdp_websocket_url,
1616                mapper_target_id.as_deref(),
1617                browsing_context_id.as_deref(),
1618                url.as_deref(),
1619            )
1620            .await?;
1621            Ok(PluginResult::ResolveBidiContextForTab {
1622                browsing_context_id: resolved_browsing_context_id,
1623                mapper,
1624            })
1625        }),
1626        PluginCommand::ClickElementViaCdp {
1627            cdp_websocket_url,
1628            target_id,
1629            css_selector,
1630        } => block_on_plugin_future(async move {
1631            let result = click_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
1632            Ok(PluginResult::ClickElementViaCdp(result))
1633        }),
1634        PluginCommand::CountElementsViaCdp {
1635            cdp_websocket_url,
1636            target_id,
1637            css_selector,
1638        } => block_on_plugin_future(async move {
1639            let result = count_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
1640            Ok(PluginResult::CountElementsViaCdp(result))
1641        }),
1642        PluginCommand::HighlightElementsViaCdp {
1643            cdp_websocket_url,
1644            target_id,
1645            css_selector,
1646            duration_ms,
1647        } => block_on_plugin_future(async move {
1648            let duration_ms = u32::try_from(duration_ms)
1649                .map_err(|_| format!("highlight duration {duration_ms} exceeds u32"))?;
1650            let result =
1651                highlight_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector, duration_ms)
1652                    .await?;
1653            Ok(PluginResult::HighlightElementsViaCdp(result))
1654        }),
1655        PluginCommand::FocusElementViaCdp {
1656            cdp_websocket_url,
1657            target_id,
1658            css_selector,
1659        } => block_on_plugin_future(async move {
1660            let result = focus_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
1661            Ok(PluginResult::FocusElementViaCdp(result))
1662        }),
1663        PluginCommand::FillElementViaCdp {
1664            cdp_websocket_url,
1665            target_id,
1666            css_selector,
1667            value,
1668        } => block_on_plugin_future(async move {
1669            let result =
1670                fill_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector, &value).await?;
1671            Ok(PluginResult::FillElementViaCdp(result))
1672        }),
1673        PluginCommand::HoverElementViaCdp {
1674            cdp_websocket_url,
1675            target_id,
1676            css_selector,
1677        } => block_on_plugin_future(async move {
1678            let result = hover_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
1679            Ok(PluginResult::HoverElementViaCdp(result))
1680        }),
1681        PluginCommand::PressKeyViaCdp {
1682            cdp_websocket_url,
1683            target_id,
1684            css_selector,
1685            key,
1686            text,
1687        } => block_on_plugin_future(async move {
1688            let result = press_key_via_cdp(
1689                &cdp_websocket_url,
1690                &target_id,
1691                &css_selector,
1692                &key,
1693                text.as_deref(),
1694            )
1695            .await?;
1696            Ok(PluginResult::PressKeyViaCdp(result))
1697        }),
1698        PluginCommand::GetTextContentViaCdp {
1699            cdp_websocket_url,
1700            target_id,
1701            css_selector,
1702        } => block_on_plugin_future(async move {
1703            let result =
1704                get_text_content_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
1705            Ok(PluginResult::GetTextContentViaCdp(result))
1706        }),
1707        PluginCommand::GetInnerTextViaCdp {
1708            cdp_websocket_url,
1709            target_id,
1710            css_selector,
1711        } => block_on_plugin_future(async move {
1712            let result = get_inner_text_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
1713            Ok(PluginResult::GetInnerTextViaCdp(result))
1714        }),
1715        PluginCommand::WaitForSelectorViaCdp {
1716            cdp_websocket_url,
1717            target_id,
1718            css_selector,
1719            visible,
1720        } => block_on_plugin_future(async move {
1721            let result =
1722                wait_for_selector_via_cdp(&cdp_websocket_url, &target_id, &css_selector, visible)
1723                    .await?;
1724            Ok(PluginResult::WaitForSelectorViaCdp(result))
1725        }),
1726    }
1727}
1728
1729#[unsafe(no_mangle)]
1730pub extern "C" fn allwright_plugin_api_version() -> u32 {
1731    ALLWRIGHT_PLUGIN_API_VERSION
1732}
1733
1734#[unsafe(no_mangle)]
1735pub extern "C" fn allwright_plugin_id() -> *const c_char {
1736    c"web".as_ptr()
1737}
1738
1739#[unsafe(no_mangle)]
1740pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
1741    if request_json.is_null() {
1742        return plugin_response(Err("plugin request pointer is null".to_string()));
1743    }
1744
1745    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
1746        Ok(request) => request,
1747        Err(error) => {
1748            return plugin_response(Err(format!(
1749                "plugin request is not valid UTF-8: {error}"
1750            )));
1751        }
1752    };
1753
1754    let command: PluginCommand = match serde_json::from_str(request) {
1755        Ok(command) => command,
1756        Err(error) => {
1757            return plugin_response(Err(format!(
1758                "failed to parse plugin request JSON: {error}"
1759            )));
1760        }
1761    };
1762
1763    plugin_response(handle_plugin_command(command))
1764}
1765
1766#[unsafe(no_mangle)]
1767pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
1768    if !value.is_null() {
1769        unsafe {
1770            let _ = CString::from_raw(value);
1771        }
1772    }
1773}