1use axum::body::Bytes;
25use axum::extract::{Query, State};
26use axum::http::{header, HeaderMap, StatusCode};
27use axum::response::IntoResponse;
28use axum::Json;
29use rdesktop_core::ipc::{IpcMessage, IpcResponse};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::sync::atomic::{AtomicU64, Ordering};
33
34use crate::server::{
35 DevServerState, PublishedScreenshot, RecordingSnapshot, RecordingStatus,
36 DEFAULT_RECORDING_MAX_DURATION_SECONDS, MAX_RECORDING_MAX_DURATION_SECONDS,
37};
38
39#[derive(Debug, Deserialize)]
41pub struct ElementQuery {
42 pub selector: Option<String>,
44
45 pub text: Option<String>,
47
48 pub role: Option<String>,
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct AgentAction {
55 #[serde(default)]
57 pub id: Option<String>,
58
59 pub action: ActionType,
61
62 pub selector: String,
64
65 pub value: Option<String>,
67
68 pub coordinates: Option<(f64, f64)>,
70
71 pub target_selector: Option<String>,
73
74 pub from: Option<(f64, f64)>,
76
77 pub to: Option<(f64, f64)>,
79
80 pub duration_ms: Option<u64>,
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ActionType {
88 Click,
89 DoubleClick,
90 RightClick,
91 Type,
92 Fill,
93 Clear,
94 Scroll,
95 Hover,
96 Focus,
97 Select,
98 Drag,
99 Press,
100}
101
102#[derive(Debug, Deserialize, Default)]
103pub struct ActionQuery {
104 pub wait: Option<bool>,
106}
107
108#[derive(Debug, Serialize)]
110pub struct DomSnapshot {
111 pub html: String,
113
114 pub url: String,
116
117 pub title: String,
119
120 pub timestamp: String,
122}
123
124#[derive(Debug, Serialize)]
126pub struct ElementInfo {
127 pub selector: String,
129
130 pub tag: String,
132
133 pub text: String,
135
136 pub attributes: HashMap<String, String>,
138
139 pub visible: bool,
141
142 pub enabled: bool,
144
145 pub role: Option<String>,
147
148 pub label: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct ActionResult {
155 pub success: bool,
157
158 pub error: Option<String>,
160
161 pub side_effects: Vec<String>,
163}
164
165#[derive(Debug, Deserialize, Default)]
166pub struct ActionResultReport {
167 pub id: String,
168 pub success: bool,
169 pub error: Option<String>,
170 #[serde(default)]
171 pub side_effects: Vec<String>,
172}
173
174static NEXT_ACTION_ID: AtomicU64 = AtomicU64::new(1);
175
176#[derive(Debug, Deserialize, Default)]
179pub struct RecordingStartRequest {
180 pub fps: Option<u32>,
181 pub max_duration_seconds: Option<u64>,
183}
184
185#[derive(Debug, Deserialize, Default)]
187pub struct RecordingStopRequest {
188 pub session_id: Option<String>,
189}
190
191#[derive(Debug, Deserialize)]
192pub struct RecordingStartedRequest {
193 pub session_id: String,
194 pub mime_type: String,
195}
196
197#[derive(Debug, Deserialize)]
198pub struct RecordingCompleteRequest {
199 pub session_id: String,
200 pub mime_type: Option<String>,
201}
202
203#[derive(Debug, Deserialize)]
204pub struct RecordingErrorRequest {
205 pub session_id: String,
206 pub error: String,
207}
208
209pub async fn get_dom(State(state): State<DevServerState>) -> impl IntoResponse {
214 let snapshot = state.last_dom_snapshot.read().await;
215
216 let html = snapshot.clone().unwrap_or_else(|| {
217 r#"<!DOCTYPE html>
218<html>
219<head><title>rdesktop</title></head>
220<body>
221 <p>No DOM snapshot available yet. Make sure the app is loaded in the browser.</p>
222 <p>The bridge script will send DOM updates automatically.</p>
223</body>
224</html>"#
225 .to_string()
226 });
227
228 let dom = DomSnapshot {
229 html,
230 url: "http://localhost".to_string(),
231 title: "rdesktop App".to_string(),
232 timestamp: timestamp(),
233 };
234
235 Json(dom).into_response()
236}
237
238pub async fn query_elements(
242 State(state): State<DevServerState>,
243 Query(query): Query<ElementQuery>,
244) -> impl IntoResponse {
245 let snapshot = state.last_dom_snapshot.read().await;
246
247 let elements: Vec<ElementInfo> = if let Some(ref html) = *snapshot {
249 find_elements(html, &query)
250 } else {
251 vec![]
252 };
253
254 Json(serde_json::json!({
255 "query": {
256 "selector": query.selector,
257 "text": query.text,
258 "role": query.role,
259 },
260 "count": elements.len(),
261 "elements": elements,
262 }))
263 .into_response()
264}
265
266pub async fn execute_action(
271 State(state): State<DevServerState>,
272 Query(query): Query<ActionQuery>,
273 Json(action): Json<AgentAction>,
274) -> impl IntoResponse {
275 let action_id = format!(
276 "action-{}-{}",
277 timestamp(),
278 NEXT_ACTION_ID.fetch_add(1, Ordering::Relaxed)
279 );
280 let mut queued_action = action.clone();
281 queued_action.id = Some(action_id.clone());
282
283 tracing::info!(
284 action = ?action.action,
285 selector = %action.selector,
286 action_id = %action_id,
287 "Agent action received"
288 );
289
290 let before_generation = state.screenshot_publisher.generation();
291 let wait_for_paint = query.wait.unwrap_or(false);
292 if wait_for_paint {
293 state.action_waiters.lock().await.insert(action_id.clone());
294 }
295 state.pending_actions.lock().await.push(queued_action);
296
297 let bridge_result = if wait_for_paint {
298 wait_for_action_result(&state, &action_id, std::time::Duration::from_secs(5)).await
299 } else {
300 None
301 };
302 if wait_for_paint {
303 state.action_waiters.lock().await.remove(&action_id);
304 }
305
306 let painted = if let Some(result) = bridge_result.as_ref() {
307 if !result.success {
308 false
309 } else {
310 let receipt_generation = state.screenshot_publisher.generation();
314 state
315 .screenshot_publisher
316 .wait_for_next(receipt_generation, std::time::Duration::from_secs(1))
317 .await
318 .is_some()
319 }
320 } else {
321 !wait_for_paint
322 || state
323 .screenshot_publisher
324 .wait_for_next(before_generation, std::time::Duration::from_secs(1))
325 .await
326 .is_some()
327 };
328
329 let result = bridge_result
330 .map(|mut result| {
331 if result.success && !painted {
332 result.success = false;
333 result.error = Some("bridge 已执行,但未在 1 秒内收到后续原生画面".to_string());
334 }
335 result
336 })
337 .unwrap_or_else(|| ActionResult {
338 success: painted,
339 error: if painted {
340 None
341 } else {
342 Some("动作已排队,但未在 5 秒内收到原生 bridge 回执".to_string())
343 },
344 side_effects: if painted {
345 vec![format!(
346 "Action {:?} on '{}' queued and painted",
347 action.action, action.selector
348 )]
349 } else {
350 vec![format!(
351 "Action {:?} on '{}' queued",
352 action.action, action.selector
353 )]
354 },
355 });
356
357 Json(result).into_response()
358}
359
360pub async fn pending_actions(State(state): State<DevServerState>) -> impl IntoResponse {
364 let mut actions = state.pending_actions.lock().await;
365 Json(std::mem::take(&mut *actions)).into_response()
366}
367
368pub async fn report_action_result(
373 State(state): State<DevServerState>,
374 Json(report): Json<ActionResultReport>,
375) -> impl IntoResponse {
376 if state.action_waiters.lock().await.contains(&report.id) {
377 state.action_results.lock().await.insert(
378 report.id,
379 ActionResult {
380 success: report.success,
381 error: report.error,
382 side_effects: report.side_effects,
383 },
384 );
385 state.action_result_notify.notify_waiters();
386 }
387 Json(serde_json::json!({ "ok": true })).into_response()
388}
389
390async fn wait_for_action_result(
391 state: &DevServerState,
392 action_id: &str,
393 timeout: std::time::Duration,
394) -> Option<ActionResult> {
395 let deadline = tokio::time::Instant::now() + timeout;
396 loop {
397 if let Some(result) = state.action_results.lock().await.remove(action_id) {
398 return Some(result);
399 }
400 let notified = state.action_result_notify.notified();
401 if let Some(result) = state.action_results.lock().await.remove(action_id) {
402 return Some(result);
403 }
404 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
405 if remaining.is_zero() {
406 return None;
407 }
408 if tokio::time::timeout(remaining, notified).await.is_err() {
409 return None;
410 }
411 }
412}
413
414pub async fn get_state(State(state): State<DevServerState>) -> impl IntoResponse {
418 let app_state = state.last_app_state.read().await;
419
420 match app_state.as_ref() {
421 Some(state) => Json(state.clone()).into_response(),
422 None => Json(serde_json::json!({
423 "message": "No application state available yet.",
424 "hint": "Use fetch('/__rdesktop__/state', { method: 'POST', body: JSON.stringify(state) }) from your app."
425 }))
426 .into_response(),
427 }
428}
429
430pub async fn send_ipc(
434 State(state): State<DevServerState>,
435 Json(message): Json<serde_json::Value>,
436) -> impl IntoResponse {
437 if let Some(handler) = state.ipc_handler.as_ref() {
438 let response = match serde_json::from_value::<IpcMessage>(message) {
439 Ok(message) => handler.handle(message),
440 Err(error) => IpcResponse {
441 id: "0".to_string(),
442 success: false,
443 data: serde_json::json!({ "error": format!("Invalid IPC message: {error}") }),
444 },
445 };
446 return Json(response).into_response();
447 }
448
449 let cmd = message["cmd"].as_str().unwrap_or("unknown");
450 let payload = message["payload"].clone();
451 let id = message["id"].as_str().unwrap_or("0");
452
453 tracing::info!(cmd = cmd, "Agent IPC message received");
454
455 let response = match cmd {
458 "greet" => {
459 let name = payload["name"].as_str().unwrap_or("World");
460 serde_json::json!({
461 "id": id,
462 "success": true,
463 "data": { "message": format!("Hello, {}!", name) }
464 })
465 }
466 "ping" => {
467 serde_json::json!({
468 "id": id,
469 "success": true,
470 "data": { "pong": true }
471 })
472 }
473 _ => {
474 serde_json::json!({
475 "id": id,
476 "success": false,
477 "data": { "error": format!("Unknown command: {}", cmd) }
478 })
479 }
480 };
481
482 Json(response).into_response()
483}
484
485#[derive(Debug, Deserialize, Default)]
489pub struct ScreenshotQuery {
490 pub wait: Option<bool>,
492 pub after: Option<u64>,
493}
494
495pub async fn take_screenshot(
497 State(state): State<DevServerState>,
498 Query(query): Query<ScreenshotQuery>,
499) -> impl IntoResponse {
500 let frame = if query.wait.unwrap_or(false) {
501 let after = query
502 .after
503 .unwrap_or_else(|| state.screenshot_publisher.generation());
504 state
505 .screenshot_publisher
506 .wait_for_next(after, std::time::Duration::from_secs(5))
507 .await
508 } else {
509 state.screenshot_publisher.latest().await
510 };
511
512 let frame = match frame {
513 Some(frame) => Some(frame),
514 None if !query.wait.unwrap_or(false) => read_persisted_screenshot(&state).await,
515 None => None,
516 };
517
518 let Some(PublishedScreenshot { generation, png }) = frame else {
519 return json_error(
520 StatusCode::NOT_FOUND,
521 "no complete native screenshot frame is available yet".to_string(),
522 );
523 };
524
525 axum::response::Response::builder()
526 .status(StatusCode::OK)
527 .header(header::CONTENT_TYPE, "image/png")
528 .header("cache-control", "no-store")
529 .header("x-rdesktop-screenshot-generation", generation.to_string())
530 .body(axum::body::Body::from(png))
531 .expect("screenshot response is valid")
532 .into_response()
533}
534
535async fn read_persisted_screenshot(state: &DevServerState) -> Option<PublishedScreenshot> {
536 let path = state.screenshot_path.as_ref()?;
537 let metadata = tokio::fs::metadata(path).await.ok()?;
538 if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 16 * 1024 * 1024 {
539 return None;
540 }
541 let png = tokio::fs::read(path).await.ok()?;
542 Some(PublishedScreenshot { generation: 0, png })
543}
544
545pub async fn get_recording(State(state): State<DevServerState>) -> impl IntoResponse {
549 Json(state.recording.snapshot().await).into_response()
550}
551
552pub async fn poll_recording(State(state): State<DevServerState>) -> impl IntoResponse {
556 Json(state.recording.snapshot().await).into_response()
557}
558
559pub async fn start_recording(
564 State(state): State<DevServerState>,
565 request: Option<Json<RecordingStartRequest>>,
566) -> impl IntoResponse {
567 let request = request.map(|Json(request)| request).unwrap_or_default();
568 let fps = request.fps.unwrap_or(30).clamp(1, 60);
569 let max_duration_seconds = request
570 .max_duration_seconds
571 .unwrap_or(DEFAULT_RECORDING_MAX_DURATION_SECONDS)
572 .clamp(1, MAX_RECORDING_MAX_DURATION_SECONDS);
573 let max_duration = std::time::Duration::from_secs(max_duration_seconds);
574 match state.recording.start_with_options(fps, max_duration).await {
575 Ok((recording, reused)) => {
576 if !reused {
577 if let Some(session_id) = recording.session_id.clone() {
578 let recording_store = state.recording.clone();
579 tokio::spawn(async move {
580 tokio::time::sleep(max_duration).await;
581 if let Err(error) = recording_store.stop(Some(&session_id)).await {
582 tracing::warn!(%error, "recording auto-stop failed");
583 }
584 });
585 }
586 }
587 Json(serde_json::json!({
588 "ok": true,
589 "reused": reused,
590 "auto_stop_seconds": max_duration_seconds,
591 "recording": recording,
592 }))
593 .into_response()
594 }
595 Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
596 }
597}
598
599pub async fn stop_recording(
604 State(state): State<DevServerState>,
605 request: Option<Json<RecordingStopRequest>>,
606) -> impl IntoResponse {
607 let session_id = request.and_then(|Json(request)| request.session_id);
608 match state.recording.stop(session_id.as_deref()).await {
609 Ok(recording) => Json(serde_json::json!({
610 "ok": true,
611 "recording": recording,
612 }))
613 .into_response(),
614 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
615 }
616}
617
618pub async fn recording_started(
622 State(state): State<DevServerState>,
623 Json(request): Json<RecordingStartedRequest>,
624) -> impl IntoResponse {
625 match state
626 .recording
627 .mark_started(&request.session_id, &request.mime_type)
628 .await
629 {
630 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
631 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
632 }
633}
634
635pub async fn recording_chunk(
640 State(state): State<DevServerState>,
641 headers: HeaderMap,
642 body: Bytes,
643) -> impl IntoResponse {
644 let Some(session_id) = header_value(&headers, "x-rdesktop-recording-id") else {
645 return json_error(
646 StatusCode::BAD_REQUEST,
647 "missing recording session header".to_string(),
648 );
649 };
650 if body.is_empty() {
651 return Json(serde_json::json!({ "ok": true, "bytes": 0 })).into_response();
652 }
653 match state.recording.append_chunk(&session_id, &body).await {
654 Ok(bytes) => Json(serde_json::json!({ "ok": true, "bytes": bytes })).into_response(),
655 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
656 }
657}
658
659pub async fn recording_complete(
661 State(state): State<DevServerState>,
662 Json(request): Json<RecordingCompleteRequest>,
663) -> impl IntoResponse {
664 match state
665 .recording
666 .complete(&request.session_id, request.mime_type.as_deref())
667 .await
668 {
669 Ok(recording) => recording_response(recording),
670 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
671 }
672}
673
674pub async fn recording_error(
676 State(state): State<DevServerState>,
677 Json(request): Json<RecordingErrorRequest>,
678) -> impl IntoResponse {
679 match state
680 .recording
681 .fail(&request.session_id, request.error)
682 .await
683 {
684 Ok(recording) => recording_response(recording),
685 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
686 }
687}
688
689pub async fn recording_file(State(state): State<DevServerState>) -> impl IntoResponse {
691 let recording = state.recording.snapshot().await;
692 if recording.status != RecordingStatus::Completed {
693 return json_error(
694 StatusCode::NOT_FOUND,
695 format!("recording is not complete: {:?}", recording.status),
696 );
697 }
698
699 match tokio::fs::read(&recording.path).await {
700 Ok(bytes) => axum::response::Response::builder()
701 .status(StatusCode::OK)
702 .header(
703 header::CONTENT_TYPE,
704 recording.mime_type.as_deref().unwrap_or("video/webm"),
705 )
706 .header(
707 header::CONTENT_DISPOSITION,
708 if recording
709 .mime_type
710 .as_deref()
711 .map(|mime| mime.starts_with("video/mp4"))
712 .unwrap_or(false)
713 {
714 "attachment; filename=recording.mp4"
715 } else {
716 "attachment; filename=recording.webm"
717 },
718 )
719 .body(axum::body::Body::from(bytes))
720 .expect("recording response is valid")
721 .into_response(),
722 Err(error) => json_error(StatusCode::NOT_FOUND, error.to_string()),
723 }
724}
725
726fn recording_response(recording: RecordingSnapshot) -> axum::response::Response {
727 Json(serde_json::json!({
728 "ok": recording.status == RecordingStatus::Completed,
729 "recording": recording,
730 }))
731 .into_response()
732}
733
734fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
735 headers
736 .get(name)
737 .and_then(|value| value.to_str().ok())
738 .map(str::to_owned)
739}
740
741fn json_error(status: StatusCode, error: String) -> axum::response::Response {
742 (
743 status,
744 Json(serde_json::json!({ "ok": false, "error": error })),
745 )
746 .into_response()
747}
748
749fn timestamp() -> String {
751 let now = std::time::SystemTime::now()
752 .duration_since(std::time::UNIX_EPOCH)
753 .unwrap_or_default();
754 format!("{}", now.as_secs())
755}
756
757fn find_elements(html: &str, query: &ElementQuery) -> Vec<ElementInfo> {
760 let mut elements = vec![];
761
762 if let Some(ref selector) = query.selector {
763 let tag = selector.trim_start_matches('<').trim_end_matches('>');
765 let open_tag = format!("<{}", tag);
766
767 let mut start = 0;
768 while let Some(pos) = html[start..].find(&open_tag) {
769 let abs_pos = start + pos;
770 let end = html[abs_pos..].find('>').unwrap_or(0);
771 let _tag_content = &html[abs_pos..abs_pos + end + 1];
772
773 let close_tag = format!("</{}>", tag);
775 let text_start = abs_pos + end + 1;
776 let text = if let Some(text_end) = html[text_start..].find(&close_tag) {
777 html[text_start..text_start + text_end].trim().to_string()
778 } else {
779 String::new()
780 };
781
782 elements.push(ElementInfo {
783 selector: format!("{}:nth-of-type({})", tag, elements.len() + 1),
784 tag: tag.to_string(),
785 text,
786 attributes: HashMap::new(),
787 visible: true,
788 enabled: true,
789 role: None,
790 label: None,
791 });
792
793 start = abs_pos + end + 1;
794 }
795 }
796
797 if let Some(ref text_query) = query.text {
798 let lower_html = html.to_lowercase();
800 let lower_query = text_query.to_lowercase();
801 if lower_html.contains(&lower_query) {
802 elements.push(ElementInfo {
803 selector: format!("*:contains(\"{}\")", text_query),
804 tag: "*".to_string(),
805 text: text_query.clone(),
806 attributes: HashMap::new(),
807 visible: true,
808 enabled: true,
809 role: None,
810 label: None,
811 });
812 }
813 }
814
815 elements
816}