1use axum::body::Bytes;
25use axum::extract::{Query, State};
26use axum::http::{header, HeaderMap, StatusCode};
27use axum::response::IntoResponse;
28use axum::Json;
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31
32use crate::server::{
33 DevServerState, RecordingSnapshot, RecordingStatus, DEFAULT_RECORDING_MAX_DURATION_SECONDS,
34 MAX_RECORDING_MAX_DURATION_SECONDS,
35};
36
37#[derive(Debug, Deserialize)]
39pub struct ElementQuery {
40 pub selector: Option<String>,
42
43 pub text: Option<String>,
45
46 pub role: Option<String>,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct AgentAction {
53 pub action: ActionType,
55
56 pub selector: String,
58
59 pub value: Option<String>,
61
62 pub coordinates: Option<(f64, f64)>,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ActionType {
70 Click,
71 DoubleClick,
72 RightClick,
73 Type,
74 Fill,
75 Clear,
76 Scroll,
77 Hover,
78 Focus,
79 Select,
80}
81
82#[derive(Debug, Serialize)]
84pub struct DomSnapshot {
85 pub html: String,
87
88 pub url: String,
90
91 pub title: String,
93
94 pub timestamp: String,
96}
97
98#[derive(Debug, Serialize)]
100pub struct ElementInfo {
101 pub selector: String,
103
104 pub tag: String,
106
107 pub text: String,
109
110 pub attributes: HashMap<String, String>,
112
113 pub visible: bool,
115
116 pub enabled: bool,
118
119 pub role: Option<String>,
121
122 pub label: Option<String>,
124}
125
126#[derive(Debug, Serialize)]
128pub struct ActionResult {
129 pub success: bool,
131
132 pub error: Option<String>,
134
135 pub side_effects: Vec<String>,
137}
138
139#[derive(Debug, Deserialize, Default)]
142pub struct RecordingStartRequest {
143 pub fps: Option<u32>,
144 pub max_duration_seconds: Option<u64>,
146}
147
148#[derive(Debug, Deserialize, Default)]
150pub struct RecordingStopRequest {
151 pub session_id: Option<String>,
152}
153
154#[derive(Debug, Deserialize)]
155pub struct RecordingStartedRequest {
156 pub session_id: String,
157 pub mime_type: String,
158}
159
160#[derive(Debug, Deserialize)]
161pub struct RecordingCompleteRequest {
162 pub session_id: String,
163 pub mime_type: Option<String>,
164}
165
166#[derive(Debug, Deserialize)]
167pub struct RecordingErrorRequest {
168 pub session_id: String,
169 pub error: String,
170}
171
172pub async fn get_dom(State(state): State<DevServerState>) -> impl IntoResponse {
177 let snapshot = state.last_dom_snapshot.read().await;
178
179 let html = snapshot.clone().unwrap_or_else(|| {
180 r#"<!DOCTYPE html>
181<html>
182<head><title>rdesktop</title></head>
183<body>
184 <p>No DOM snapshot available yet. Make sure the app is loaded in the browser.</p>
185 <p>The bridge script will send DOM updates automatically.</p>
186</body>
187</html>"#
188 .to_string()
189 });
190
191 let dom = DomSnapshot {
192 html,
193 url: "http://localhost".to_string(),
194 title: "rdesktop App".to_string(),
195 timestamp: timestamp(),
196 };
197
198 Json(dom).into_response()
199}
200
201pub async fn query_elements(
205 State(state): State<DevServerState>,
206 Query(query): Query<ElementQuery>,
207) -> impl IntoResponse {
208 let snapshot = state.last_dom_snapshot.read().await;
209
210 let elements: Vec<ElementInfo> = if let Some(ref html) = *snapshot {
212 find_elements(html, &query)
213 } else {
214 vec![]
215 };
216
217 Json(serde_json::json!({
218 "query": {
219 "selector": query.selector,
220 "text": query.text,
221 "role": query.role,
222 },
223 "count": elements.len(),
224 "elements": elements,
225 }))
226 .into_response()
227}
228
229pub async fn execute_action(
234 State(state): State<DevServerState>,
235 Json(action): Json<AgentAction>,
236) -> impl IntoResponse {
237 tracing::info!(
238 action = ?action.action,
239 selector = %action.selector,
240 "Agent action received"
241 );
242
243 state.pending_actions.lock().await.push(action.clone());
244
245 let result = ActionResult {
246 success: true,
247 error: None,
248 side_effects: vec![format!(
249 "Action {:?} on '{}' queued",
250 action.action, action.selector
251 )],
252 };
253
254 Json(result).into_response()
255}
256
257pub async fn pending_actions(State(state): State<DevServerState>) -> impl IntoResponse {
261 let mut actions = state.pending_actions.lock().await;
262 Json(std::mem::take(&mut *actions)).into_response()
263}
264
265pub async fn get_state(State(state): State<DevServerState>) -> impl IntoResponse {
269 let app_state = state.last_app_state.read().await;
270
271 match app_state.as_ref() {
272 Some(state) => Json(state.clone()).into_response(),
273 None => Json(serde_json::json!({
274 "message": "No application state available yet.",
275 "hint": "Use fetch('/__rdesktop__/state', { method: 'POST', body: JSON.stringify(state) }) from your app."
276 }))
277 .into_response(),
278 }
279}
280
281pub async fn send_ipc(
285 State(_state): State<DevServerState>,
286 Json(message): Json<serde_json::Value>,
287) -> impl IntoResponse {
288 let cmd = message["cmd"].as_str().unwrap_or("unknown");
289 let payload = message["payload"].clone();
290 let id = message["id"].as_str().unwrap_or("0");
291
292 tracing::info!(cmd = cmd, "Agent IPC message received");
293
294 let response = match cmd {
297 "greet" => {
298 let name = payload["name"].as_str().unwrap_or("World");
299 serde_json::json!({
300 "id": id,
301 "success": true,
302 "data": { "message": format!("Hello, {}!", name) }
303 })
304 }
305 "ping" => {
306 serde_json::json!({
307 "id": id,
308 "success": true,
309 "data": { "pong": true }
310 })
311 }
312 _ => {
313 serde_json::json!({
314 "id": id,
315 "success": false,
316 "data": { "error": format!("Unknown command: {}", cmd) }
317 })
318 }
319 };
320
321 Json(response).into_response()
322}
323
324pub async fn take_screenshot(State(_state): State<DevServerState>) -> impl IntoResponse {
328 (
329 StatusCode::NOT_IMPLEMENTED,
330 Json(serde_json::json!({
331 "message": "Screenshot not implemented in browser mode.",
332 "hint": "Use Playwright's page.screenshot() directly."
333 })),
334 )
335 .into_response()
336}
337
338pub async fn get_recording(State(state): State<DevServerState>) -> impl IntoResponse {
342 Json(state.recording.snapshot().await).into_response()
343}
344
345pub async fn poll_recording(State(state): State<DevServerState>) -> impl IntoResponse {
349 Json(state.recording.snapshot().await).into_response()
350}
351
352pub async fn start_recording(
357 State(state): State<DevServerState>,
358 request: Option<Json<RecordingStartRequest>>,
359) -> impl IntoResponse {
360 let request = request.map(|Json(request)| request).unwrap_or_default();
361 let fps = request.fps.unwrap_or(30).clamp(1, 60);
362 let max_duration_seconds = request
363 .max_duration_seconds
364 .unwrap_or(DEFAULT_RECORDING_MAX_DURATION_SECONDS)
365 .clamp(1, MAX_RECORDING_MAX_DURATION_SECONDS);
366 let max_duration = std::time::Duration::from_secs(max_duration_seconds);
367 match state.recording.start_with_options(fps, max_duration).await {
368 Ok((recording, reused)) => {
369 if !reused {
370 if let Some(session_id) = recording.session_id.clone() {
371 let recording_store = state.recording.clone();
372 tokio::spawn(async move {
373 tokio::time::sleep(max_duration).await;
374 if let Err(error) = recording_store.stop(Some(&session_id)).await {
375 tracing::warn!(%error, "recording auto-stop failed");
376 }
377 });
378 }
379 }
380 Json(serde_json::json!({
381 "ok": true,
382 "reused": reused,
383 "auto_stop_seconds": max_duration_seconds,
384 "recording": recording,
385 }))
386 .into_response()
387 }
388 Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
389 }
390}
391
392pub async fn stop_recording(
397 State(state): State<DevServerState>,
398 request: Option<Json<RecordingStopRequest>>,
399) -> impl IntoResponse {
400 let session_id = request.and_then(|Json(request)| request.session_id);
401 match state.recording.stop(session_id.as_deref()).await {
402 Ok(recording) => Json(serde_json::json!({
403 "ok": true,
404 "recording": recording,
405 }))
406 .into_response(),
407 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
408 }
409}
410
411pub async fn recording_started(
415 State(state): State<DevServerState>,
416 Json(request): Json<RecordingStartedRequest>,
417) -> impl IntoResponse {
418 match state
419 .recording
420 .mark_started(&request.session_id, &request.mime_type)
421 .await
422 {
423 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
424 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
425 }
426}
427
428pub async fn recording_chunk(
433 State(state): State<DevServerState>,
434 headers: HeaderMap,
435 body: Bytes,
436) -> impl IntoResponse {
437 let Some(session_id) = header_value(&headers, "x-rdesktop-recording-id") else {
438 return json_error(
439 StatusCode::BAD_REQUEST,
440 "missing recording session header".to_string(),
441 );
442 };
443 if body.is_empty() {
444 return Json(serde_json::json!({ "ok": true, "bytes": 0 })).into_response();
445 }
446 match state.recording.append_chunk(&session_id, &body).await {
447 Ok(bytes) => Json(serde_json::json!({ "ok": true, "bytes": bytes })).into_response(),
448 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
449 }
450}
451
452pub async fn recording_complete(
454 State(state): State<DevServerState>,
455 Json(request): Json<RecordingCompleteRequest>,
456) -> impl IntoResponse {
457 match state
458 .recording
459 .complete(&request.session_id, request.mime_type.as_deref())
460 .await
461 {
462 Ok(recording) => recording_response(recording),
463 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
464 }
465}
466
467pub async fn recording_error(
469 State(state): State<DevServerState>,
470 Json(request): Json<RecordingErrorRequest>,
471) -> impl IntoResponse {
472 match state
473 .recording
474 .fail(&request.session_id, request.error)
475 .await
476 {
477 Ok(recording) => recording_response(recording),
478 Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
479 }
480}
481
482pub async fn recording_file(State(state): State<DevServerState>) -> impl IntoResponse {
484 let recording = state.recording.snapshot().await;
485 if recording.status != RecordingStatus::Completed {
486 return json_error(
487 StatusCode::NOT_FOUND,
488 format!("recording is not complete: {:?}", recording.status),
489 );
490 }
491
492 match tokio::fs::read(&recording.path).await {
493 Ok(bytes) => axum::response::Response::builder()
494 .status(StatusCode::OK)
495 .header(
496 header::CONTENT_TYPE,
497 recording.mime_type.as_deref().unwrap_or("video/webm"),
498 )
499 .header(
500 header::CONTENT_DISPOSITION,
501 if recording
502 .mime_type
503 .as_deref()
504 .map(|mime| mime.starts_with("video/mp4"))
505 .unwrap_or(false)
506 {
507 "attachment; filename=recording.mp4"
508 } else {
509 "attachment; filename=recording.webm"
510 },
511 )
512 .body(axum::body::Body::from(bytes))
513 .expect("recording response is valid")
514 .into_response(),
515 Err(error) => json_error(StatusCode::NOT_FOUND, error.to_string()),
516 }
517}
518
519fn recording_response(recording: RecordingSnapshot) -> axum::response::Response {
520 Json(serde_json::json!({
521 "ok": recording.status == RecordingStatus::Completed,
522 "recording": recording,
523 }))
524 .into_response()
525}
526
527fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
528 headers
529 .get(name)
530 .and_then(|value| value.to_str().ok())
531 .map(str::to_owned)
532}
533
534fn json_error(status: StatusCode, error: String) -> axum::response::Response {
535 (
536 status,
537 Json(serde_json::json!({ "ok": false, "error": error })),
538 )
539 .into_response()
540}
541
542fn timestamp() -> String {
544 let now = std::time::SystemTime::now()
545 .duration_since(std::time::UNIX_EPOCH)
546 .unwrap_or_default();
547 format!("{}", now.as_secs())
548}
549
550fn find_elements(html: &str, query: &ElementQuery) -> Vec<ElementInfo> {
553 let mut elements = vec![];
554
555 if let Some(ref selector) = query.selector {
556 let tag = selector.trim_start_matches('<').trim_end_matches('>');
558 let open_tag = format!("<{}", tag);
559
560 let mut start = 0;
561 while let Some(pos) = html[start..].find(&open_tag) {
562 let abs_pos = start + pos;
563 let end = html[abs_pos..].find('>').unwrap_or(0);
564 let _tag_content = &html[abs_pos..abs_pos + end + 1];
565
566 let close_tag = format!("</{}>", tag);
568 let text_start = abs_pos + end + 1;
569 let text = if let Some(text_end) = html[text_start..].find(&close_tag) {
570 html[text_start..text_start + text_end].trim().to_string()
571 } else {
572 String::new()
573 };
574
575 elements.push(ElementInfo {
576 selector: format!("{}:nth-of-type({})", tag, elements.len() + 1),
577 tag: tag.to_string(),
578 text,
579 attributes: HashMap::new(),
580 visible: true,
581 enabled: true,
582 role: None,
583 label: None,
584 });
585
586 start = abs_pos + end + 1;
587 }
588 }
589
590 if let Some(ref text_query) = query.text {
591 let lower_html = html.to_lowercase();
593 let lower_query = text_query.to_lowercase();
594 if lower_html.contains(&lower_query) {
595 elements.push(ElementInfo {
596 selector: format!("*:contains(\"{}\")", text_query),
597 tag: "*".to_string(),
598 text: text_query.clone(),
599 attributes: HashMap::new(),
600 visible: true,
601 enabled: true,
602 role: None,
603 label: None,
604 });
605 }
606 }
607
608 elements
609}