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}