1use std::sync::Arc;
7
8use axum::extract::State;
9use axum::http::{HeaderMap, StatusCode};
10use axum::Json;
11use serde_json::json;
12
13use super::cdp::CdpSession;
14use super::types::{
15 AckData, ApiResponse, ClickElementReq, DispatchKeyReq, Element, EvalData, EvalReq,
16 FocusElementReq, FocusInfoData, FocusInfoReq, HistoryReq, NavigateReq, QueryData, QueryReq,
17 ScreenshotData, ScreenshotReq,
18};
19use crate::state::AppState;
20
21pub async fn query(
25 State(state): State<Arc<AppState>>,
26 headers: HeaderMap,
27 Json(req): Json<QueryReq>,
28) -> (StatusCode, Json<ApiResponse<QueryData>>) {
29 if !authorized(&headers, &state.ipc_token) {
30 return (
31 StatusCode::UNAUTHORIZED,
32 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
33 );
34 }
35
36 let target = match state
38 .browser_api
39 .target_cache
40 .resolve(&state, &req.block_id)
41 .await
42 {
43 Ok(t) => t,
44 Err(e) => return ok_body(ApiResponse::err(e)),
45 };
46
47 let debug_port = *state.debug_port.lock();
49 let ws_url = format!("ws://127.0.0.1:{debug_port}/devtools/page/{target}");
50 let mut cdp = match CdpSession::connect(&ws_url).await {
51 Ok(s) => s,
52 Err(e) => {
53 state.browser_api.target_cache.forget(&req.block_id);
55 return ok_body(ApiResponse::err(format!("CDP connect: {e}")));
56 }
57 };
58
59 let helper = include_str!("scripts/query.js");
61 if let Err(e) = cdp
62 .call(
63 "Runtime.evaluate",
64 json!({
65 "expression": helper,
66 "returnByValue": false,
67 }),
68 )
69 .await
70 {
71 let _ = cdp.close().await;
72 return ok_body(ApiResponse::err(format!("CDP inject helper: {e}")));
73 }
74
75 let selector_js = serde_json::to_string(&req.selector)
78 .unwrap_or_else(|_| "\"\"".to_string());
79 let call_expr = format!(
80 "__amq_query({sel}, {lim})",
81 sel = selector_js,
82 lim = req.limit.unwrap_or(0),
83 );
84 let eval_result = match cdp
85 .call(
86 "Runtime.evaluate",
87 json!({
88 "expression": call_expr,
89 "returnByValue": true,
90 "awaitPromise": false,
91 }),
92 )
93 .await
94 {
95 Ok(v) => v,
96 Err(e) => {
97 let _ = cdp.close().await;
98 return ok_body(ApiResponse::err(format!("CDP call __amq_query: {e}")));
99 }
100 };
101
102 let _ = cdp.close().await;
103
104 let value = eval_result
107 .get("result")
108 .and_then(|r| r.get("value"))
109 .cloned()
110 .unwrap_or(serde_json::Value::Null);
111
112 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
114 return ok_body(ApiResponse::err(format!("DOM query error: {err}")));
115 }
116
117 let matches: Vec<Element> = value
119 .get("matches")
120 .and_then(|m| serde_json::from_value(m.clone()).ok())
121 .unwrap_or_default();
122
123 ok_body(ApiResponse::ok(QueryData { matches }))
124}
125
126pub async fn focus_info(
129 State(state): State<Arc<AppState>>,
130 headers: HeaderMap,
131 Json(req): Json<FocusInfoReq>,
132) -> (StatusCode, Json<ApiResponse<FocusInfoData>>) {
133 if !authorized(&headers, &state.ipc_token) {
134 return (
135 StatusCode::UNAUTHORIZED,
136 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
137 );
138 }
139
140 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
141 Ok(c) => c,
142 Err(e) => return ok_body(ApiResponse::err(e)),
143 };
144
145 let helper = include_str!("scripts/query.js");
147 if let Err(e) = cdp
148 .call(
149 "Runtime.evaluate",
150 json!({ "expression": helper, "returnByValue": false }),
151 )
152 .await
153 {
154 let _ = cdp.close().await;
155 return ok_body(ApiResponse::err(format!("CDP inject helper: {e}")));
156 }
157
158 let eval_result = match cdp
159 .call(
160 "Runtime.evaluate",
161 json!({
162 "expression": "__amq_focus_info()",
163 "returnByValue": true,
164 }),
165 )
166 .await
167 {
168 Ok(v) => v,
169 Err(e) => {
170 let _ = cdp.close().await;
171 return ok_body(ApiResponse::err(format!("CDP call __amq_focus_info: {e}")));
172 }
173 };
174
175 let _ = cdp.close().await;
176
177 let value = eval_result
178 .get("result")
179 .and_then(|r| r.get("value"))
180 .cloned()
181 .unwrap_or(serde_json::Value::Null);
182
183 let focused: Option<Element> = if value.is_null() {
185 None
186 } else {
187 match serde_json::from_value(value) {
188 Ok(e) => Some(e),
189 Err(e) => return ok_body(ApiResponse::err(format!("parse element: {e}"))),
190 }
191 };
192 ok_body(ApiResponse::ok(FocusInfoData { focused }))
193}
194
195pub async fn eval(
202 State(state): State<Arc<AppState>>,
203 headers: HeaderMap,
204 Json(req): Json<EvalReq>,
205) -> (StatusCode, Json<ApiResponse<EvalData>>) {
206 if !authorized(&headers, &state.ipc_token) {
207 return (
208 StatusCode::UNAUTHORIZED,
209 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
210 );
211 }
212
213 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
214 Ok(c) => c,
215 Err(e) => return ok_body(ApiResponse::err(e)),
216 };
217
218 let eval_result = match cdp
219 .call(
220 "Runtime.evaluate",
221 json!({
222 "expression": req.script,
223 "returnByValue": true,
224 "awaitPromise": req.await_promise,
225 }),
226 )
227 .await
228 {
229 Ok(v) => v,
230 Err(e) => {
231 let _ = cdp.close().await;
232 return ok_body(ApiResponse::err(format!("CDP eval: {e}")));
233 }
234 };
235
236 let _ = cdp.close().await;
237
238 if let Some(exc) = eval_result.get("exceptionDetails") {
241 let msg = exc
242 .get("exception")
243 .and_then(|e| e.get("description"))
244 .and_then(|d| d.as_str())
245 .or_else(|| exc.get("text").and_then(|t| t.as_str()))
246 .unwrap_or("unknown exception")
247 .to_string();
248 return ok_body(ApiResponse::ok(EvalData {
249 result: serde_json::Value::Null,
250 type_: "undefined".to_string(),
251 exception: Some(msg),
252 }));
253 }
254
255 let result = eval_result.get("result").cloned().unwrap_or_default();
256 let type_ = result
257 .get("type")
258 .and_then(|t| t.as_str())
259 .unwrap_or("undefined")
260 .to_string();
261 let value = result.get("value").cloned().unwrap_or(serde_json::Value::Null);
262
263 ok_body(ApiResponse::ok(EvalData {
264 result: value,
265 type_,
266 exception: None,
267 }))
268}
269
270pub async fn screenshot(
273 State(state): State<Arc<AppState>>,
274 headers: HeaderMap,
275 Json(req): Json<ScreenshotReq>,
276) -> (StatusCode, Json<ApiResponse<ScreenshotData>>) {
277 if !authorized(&headers, &state.ipc_token) {
278 return (
279 StatusCode::UNAUTHORIZED,
280 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
281 );
282 }
283
284 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
285 Ok(c) => c,
286 Err(e) => return ok_body(ApiResponse::err(e)),
287 };
288
289 let cap = match cdp
290 .call(
291 "Page.captureScreenshot",
292 json!({
293 "format": "png",
294 "fromSurface": true,
295 }),
296 )
297 .await
298 {
299 Ok(v) => v,
300 Err(e) => {
301 let _ = cdp.close().await;
302 return ok_body(ApiResponse::err(format!("CDP Page.captureScreenshot: {e}")));
303 }
304 };
305
306 let _ = cdp.close().await;
307
308 let data = match cap.get("data").and_then(|d| d.as_str()) {
309 Some(s) => s.to_string(),
310 None => {
311 return ok_body(ApiResponse::err(
312 "Page.captureScreenshot returned no `data` field".to_string(),
313 ))
314 }
315 };
316
317 ok_body(ApiResponse::ok(ScreenshotData { png_base64: data }))
318}
319
320pub async fn click_element(
330 State(state): State<Arc<AppState>>,
331 headers: HeaderMap,
332 Json(req): Json<ClickElementReq>,
333) -> (StatusCode, Json<ApiResponse<AckData>>) {
334 if !authorized(&headers, &state.ipc_token) {
335 return (
336 StatusCode::UNAUTHORIZED,
337 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
338 );
339 }
340
341 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
342 Ok(c) => c,
343 Err(e) => return ok_body(ApiResponse::err(e)),
344 };
345
346 let helper = include_str!("scripts/query.js");
348 if let Err(e) = cdp
349 .call(
350 "Runtime.evaluate",
351 json!({ "expression": helper, "returnByValue": false }),
352 )
353 .await
354 {
355 let _ = cdp.close().await;
356 return ok_body(ApiResponse::err(format!("CDP inject helper: {e}")));
357 }
358
359 let selector_js = serde_json::to_string(&req.selector).unwrap_or_else(|_| "\"\"".into());
361 let centroid_expr = format!("__amq_centroid_of({selector_js})");
362 let cent_reply = match cdp
363 .call(
364 "Runtime.evaluate",
365 json!({
366 "expression": centroid_expr,
367 "returnByValue": true,
368 }),
369 )
370 .await
371 {
372 Ok(v) => v,
373 Err(e) => {
374 let _ = cdp.close().await;
375 return ok_body(ApiResponse::err(format!("CDP centroid query: {e}")));
376 }
377 };
378
379 let cent = cent_reply
380 .get("result")
381 .and_then(|r| r.get("value"))
382 .cloned()
383 .unwrap_or(serde_json::Value::Null);
384 if cent.is_null() {
385 let _ = cdp.close().await;
386 return ok_body(ApiResponse::err(format!(
387 "selector {:?} matched no element",
388 req.selector
389 )));
390 }
391
392 let x = cent.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
393 let y = cent.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
394
395 for event_type in ["mousePressed", "mouseReleased"] {
398 if let Err(e) = cdp
399 .call(
400 "Input.dispatchMouseEvent",
401 json!({
402 "type": event_type,
403 "x": x,
404 "y": y,
405 "button": "left",
406 "buttons": 1,
407 "clickCount": 1,
408 }),
409 )
410 .await
411 {
412 let _ = cdp.close().await;
413 return ok_body(ApiResponse::err(format!("CDP dispatchMouseEvent {event_type}: {e}")));
414 }
415 }
416
417 let _ = cdp.close().await;
418 ok_body(ApiResponse::ok(AckData::new()))
419}
420
421pub async fn focus_element(
425 State(state): State<Arc<AppState>>,
426 headers: HeaderMap,
427 Json(req): Json<FocusElementReq>,
428) -> (StatusCode, Json<ApiResponse<AckData>>) {
429 if !authorized(&headers, &state.ipc_token) {
430 return (
431 StatusCode::UNAUTHORIZED,
432 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
433 );
434 }
435
436 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
437 Ok(c) => c,
438 Err(e) => return ok_body(ApiResponse::err(e)),
439 };
440
441 let selector_js = serde_json::to_string(&req.selector).unwrap_or_else(|_| "\"\"".into());
442 let script = format!(
443 "(() => {{ const e = document.querySelector({sel}); \
444 if (!e) return false; e.focus(); return true; }})()",
445 sel = selector_js,
446 );
447
448 let reply = match cdp
449 .call(
450 "Runtime.evaluate",
451 json!({
452 "expression": script,
453 "returnByValue": true,
454 }),
455 )
456 .await
457 {
458 Ok(v) => v,
459 Err(e) => {
460 let _ = cdp.close().await;
461 return ok_body(ApiResponse::err(format!("CDP focus_element: {e}")));
462 }
463 };
464 let _ = cdp.close().await;
465
466 let got = reply
467 .get("result")
468 .and_then(|r| r.get("value"))
469 .and_then(|v| v.as_bool())
470 .unwrap_or(false);
471 if !got {
472 return ok_body(ApiResponse::err(format!(
473 "selector {:?} matched no element",
474 req.selector
475 )));
476 }
477 ok_body(ApiResponse::ok(AckData::new()))
478}
479
480pub async fn dispatch_key(
484 State(state): State<Arc<AppState>>,
485 headers: HeaderMap,
486 Json(req): Json<DispatchKeyReq>,
487) -> (StatusCode, Json<ApiResponse<AckData>>) {
488 if !authorized(&headers, &state.ipc_token) {
489 return (
490 StatusCode::UNAUTHORIZED,
491 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
492 );
493 }
494
495 if req.text.is_some() == req.key.is_some() {
497 return ok_body(ApiResponse::err(
498 "dispatch_key requires exactly one of `text` or `key`".to_string(),
499 ));
500 }
501
502 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
503 Ok(c) => c,
504 Err(e) => return ok_body(ApiResponse::err(e)),
505 };
506
507 if let Some(sel) = &req.selector {
509 let sel_js = serde_json::to_string(sel).unwrap_or_else(|_| "\"\"".into());
510 let script = format!(
511 "(() => {{ const e = document.querySelector({sel_js}); \
512 if (!e) return false; e.focus(); return true; }})()"
513 );
514 let reply = cdp
515 .call(
516 "Runtime.evaluate",
517 json!({ "expression": script, "returnByValue": true }),
518 )
519 .await;
520 let ok_focus = reply
521 .as_ref()
522 .ok()
523 .and_then(|v| v.get("result"))
524 .and_then(|r| r.get("value"))
525 .and_then(|v| v.as_bool())
526 .unwrap_or(false);
527 if !ok_focus {
528 let _ = cdp.close().await;
529 return ok_body(ApiResponse::err(format!(
530 "dispatch_key: selector {sel:?} matched no element"
531 )));
532 }
533 }
534
535 if let Some(text) = &req.text {
536 if let Err(e) = cdp
539 .call("Input.insertText", json!({ "text": text }))
540 .await
541 {
542 let _ = cdp.close().await;
543 return ok_body(ApiResponse::err(format!("CDP Input.insertText: {e}")));
544 }
545 } else if let Some(key) = &req.key {
546 let (key_name, code, windows_virtual_key_code) = match key.as_str() {
547 "Enter" => ("Enter", "Enter", 13),
548 "Tab" => ("Tab", "Tab", 9),
549 "Escape" => ("Escape", "Escape", 27),
550 "Backspace" => ("Backspace", "Backspace", 8),
551 "ArrowUp" => ("ArrowUp", "ArrowUp", 38),
552 "ArrowDown" => ("ArrowDown", "ArrowDown", 40),
553 "ArrowLeft" => ("ArrowLeft", "ArrowLeft", 37),
554 "ArrowRight" => ("ArrowRight", "ArrowRight", 39),
555 "Space" => (" ", "Space", 32),
556 other => {
557 let _ = cdp.close().await;
558 return ok_body(ApiResponse::err(format!(
559 "dispatch_key: unsupported key name {other:?} — supported: \
560 Enter, Tab, Escape, Backspace, ArrowUp/Down/Left/Right, Space"
561 )));
562 }
563 };
564
565 for event_type in ["keyDown", "keyUp"] {
566 if let Err(e) = cdp
567 .call(
568 "Input.dispatchKeyEvent",
569 json!({
570 "type": event_type,
571 "key": key_name,
572 "code": code,
573 "windowsVirtualKeyCode": windows_virtual_key_code,
574 "nativeVirtualKeyCode": windows_virtual_key_code,
575 }),
576 )
577 .await
578 {
579 let _ = cdp.close().await;
580 return ok_body(ApiResponse::err(format!(
581 "CDP Input.dispatchKeyEvent {event_type}: {e}"
582 )));
583 }
584 }
585 }
586
587 let _ = cdp.close().await;
588 ok_body(ApiResponse::ok(AckData::new()))
589}
590
591pub async fn navigate(
597 State(state): State<Arc<AppState>>,
598 headers: HeaderMap,
599 Json(req): Json<NavigateReq>,
600) -> (StatusCode, Json<ApiResponse<AckData>>) {
601 if !authorized(&headers, &state.ipc_token) {
602 return (
603 StatusCode::UNAUTHORIZED,
604 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
605 );
606 }
607
608 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
609 Ok(c) => c,
610 Err(e) => return ok_body(ApiResponse::err(e)),
611 };
612
613 let reply = cdp.call("Page.navigate", json!({ "url": req.url })).await;
614 let _ = cdp.close().await;
615
616 state.browser_api.target_cache.forget(&req.block_id);
619
620 match reply {
621 Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
622 Err(e) => ok_body(ApiResponse::err(format!("CDP Page.navigate: {e}"))),
623 }
624}
625
626pub async fn back(
635 State(state): State<Arc<AppState>>,
636 headers: HeaderMap,
637 Json(req): Json<HistoryReq>,
638) -> (StatusCode, Json<ApiResponse<AckData>>) {
639 if !authorized(&headers, &state.ipc_token) {
640 return (
641 StatusCode::UNAUTHORIZED,
642 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
643 );
644 }
645
646 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
647 Ok(c) => c,
648 Err(e) => return ok_body(ApiResponse::err(e)),
649 };
650
651 let reply = cdp.call("Page.goBack", json!({})).await;
654 let _ = cdp.close().await;
655
656 state.browser_api.target_cache.forget(&req.block_id);
657
658 match reply {
659 Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
660 Err(e) => ok_body(ApiResponse::err(format!("CDP Page.goBack: {e}"))),
661 }
662}
663
664pub async fn forward(
667 State(state): State<Arc<AppState>>,
668 headers: HeaderMap,
669 Json(req): Json<HistoryReq>,
670) -> (StatusCode, Json<ApiResponse<AckData>>) {
671 if !authorized(&headers, &state.ipc_token) {
672 return (
673 StatusCode::UNAUTHORIZED,
674 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
675 );
676 }
677
678 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
679 Ok(c) => c,
680 Err(e) => return ok_body(ApiResponse::err(e)),
681 };
682
683 let reply = cdp.call("Page.goForward", json!({})).await;
684 let _ = cdp.close().await;
685
686 state.browser_api.target_cache.forget(&req.block_id);
687
688 match reply {
689 Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
690 Err(e) => ok_body(ApiResponse::err(format!("CDP Page.goForward: {e}"))),
691 }
692}
693
694pub async fn reload(
699 State(state): State<Arc<AppState>>,
700 headers: HeaderMap,
701 Json(req): Json<HistoryReq>,
702) -> (StatusCode, Json<ApiResponse<AckData>>) {
703 if !authorized(&headers, &state.ipc_token) {
704 return (
705 StatusCode::UNAUTHORIZED,
706 Json(ApiResponse::err("unauthorized: missing or invalid bearer token")),
707 );
708 }
709
710 let mut cdp = match open_cdp_for_block(&state, &req.block_id).await {
711 Ok(c) => c,
712 Err(e) => return ok_body(ApiResponse::err(e)),
713 };
714
715 let reply = cdp
716 .call("Page.reload", json!({ "ignoreCache": req.ignore_cache }))
717 .await;
718 let _ = cdp.close().await;
719
720 match reply {
721 Ok(_) => ok_body(ApiResponse::ok(AckData::new())),
722 Err(e) => ok_body(ApiResponse::err(format!("CDP Page.reload: {e}"))),
723 }
724}
725
726async fn open_cdp_for_block(
729 state: &Arc<AppState>,
730 block_id: &str,
731) -> Result<CdpSession, String> {
732 let target = state
733 .browser_api
734 .target_cache
735 .resolve(state, block_id)
736 .await?;
737 let debug_port = *state.debug_port.lock();
738 let ws_url = format!("ws://127.0.0.1:{debug_port}/devtools/page/{target}");
739 CdpSession::connect(&ws_url).await.map_err(|e| {
740 state.browser_api.target_cache.forget(block_id);
743 format!("CDP connect: {e}")
744 })
745}
746
747fn authorized(headers: &HeaderMap, expected: &str) -> bool {
748 headers
749 .get("authorization")
750 .and_then(|v| v.to_str().ok())
751 .and_then(|v| v.strip_prefix("Bearer "))
752 .map(|token| token == expected)
753 .unwrap_or(false)
754}
755
756fn ok_body<T>(body: ApiResponse<T>) -> (StatusCode, Json<ApiResponse<T>>) {
757 (StatusCode::OK, Json(body))
758}