Skip to main content

rdesktop_dev/
agent_api.rs

1//! Agent API endpoints.
2//!
3//! These endpoints allow AI agents to interact with the running application
4//! through structured HTTP requests, without needing native desktop control.
5//!
6//! ## How it works
7//!
8//! 1. The browser app includes the rdesktop bridge script
9//! 2. The bridge script periodically sends DOM snapshots to the server
10//! 3. Agents query the server for DOM/state information
11//! 4. Agents send actions to the server, which forwards them to the browser
12//!
13//! ## Design Philosophy
14//!
15//! Instead of requiring agents to take screenshots and use vision models to
16//! understand the UI, the Agent API provides direct DOM access and structured
17//! state information. This is:
18//!
19//! - **Faster**: No screenshot encoding/decoding overhead
20//! - **More reliable**: Exact element selectors, not pixel coordinates
21//! - **More informative**: Full DOM tree, computed styles, accessibility info
22//! - **Easier to test**: Standard HTTP endpoints, can be scripted
23
24use 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/// Query parameters for element selection.
38#[derive(Debug, Deserialize)]
39pub struct ElementQuery {
40    /// CSS selector to query elements
41    pub selector: Option<String>,
42
43    /// Text content to search for
44    pub text: Option<String>,
45
46    /// Role attribute to filter by
47    pub role: Option<String>,
48}
49
50/// An action that an agent can execute on the UI.
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct AgentAction {
53    /// The type of action
54    pub action: ActionType,
55
56    /// CSS selector of the target element
57    pub selector: String,
58
59    /// Value for type/fill actions
60    pub value: Option<String>,
61
62    /// Coordinates for scroll actions
63    pub coordinates: Option<(f64, f64)>,
64}
65
66/// Types of actions agents can execute.
67#[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/// Response from a DOM query.
83#[derive(Debug, Serialize)]
84pub struct DomSnapshot {
85    /// The full HTML content
86    pub html: String,
87
88    /// The page URL
89    pub url: String,
90
91    /// The page title
92    pub title: String,
93
94    /// Timestamp of the snapshot
95    pub timestamp: String,
96}
97
98/// Response from an element query.
99#[derive(Debug, Serialize)]
100pub struct ElementInfo {
101    /// CSS selector that uniquely identifies this element
102    pub selector: String,
103
104    /// Tag name
105    pub tag: String,
106
107    /// Text content
108    pub text: String,
109
110    /// Element attributes
111    pub attributes: HashMap<String, String>,
112
113    /// Whether the element is visible
114    pub visible: bool,
115
116    /// Whether the element is enabled (for interactive elements)
117    pub enabled: bool,
118
119    /// Accessibility role
120    pub role: Option<String>,
121
122    /// Accessibility label
123    pub label: Option<String>,
124}
125
126/// Result of an action execution.
127#[derive(Debug, Serialize)]
128pub struct ActionResult {
129    /// Whether the action succeeded
130    pub success: bool,
131
132    /// Error message if the action failed
133    pub error: Option<String>,
134
135    /// Any side effects (e.g., navigation that occurred)
136    pub side_effects: Vec<String>,
137}
138
139/// Optional request body for starting a recording. The server owns the
140/// recording identity; agents may safely send `{}` more than once.
141#[derive(Debug, Deserialize, Default)]
142pub struct RecordingStartRequest {
143    pub fps: Option<u32>,
144    /// Safety limit for forgotten recordings. Defaults to five minutes.
145    pub max_duration_seconds: Option<u64>,
146}
147
148/// Optional session guard for stopping a recording.
149#[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
172/// GET /__rdesktop__/agent/dom
173///
174/// Returns a full DOM snapshot of the current page.
175/// The snapshot is collected from the browser via the bridge script.
176pub 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
201/// GET /__rdesktop__/agent/elements?selector=...
202///
203/// Query elements matching a CSS selector or text content.
204pub 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    // Parse the DOM and find matching elements
211    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
229/// POST /__rdesktop__/agent/action
230///
231/// Execute a UI action (click, type, scroll, etc.)
232/// The action is stored and picked up by the bridge script.
233pub 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
257/// GET /__rdesktop__/agent/action/pending
258///
259/// Drain actions queued by agents. The bridge polls this endpoint.
260pub 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
265/// GET /__rdesktop__/agent/state
266///
267/// Get the current application state.
268pub 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
281/// POST /__rdesktop__/agent/ipc
282///
283/// Send an IPC message from the agent to the app.
284pub 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    // In a full implementation, this would forward to the Rust IPC handler.
295    // For now, handle basic commands directly.
296    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
324/// GET /__rdesktop__/agent/screenshot
325///
326/// Capture a screenshot. In browser mode, this delegates to the browser.
327pub 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
338/// GET /__rdesktop__/agent/recording
339///
340/// Return the one recording session owned by this dev server.
341pub async fn get_recording(State(state): State<DevServerState>) -> impl IntoResponse {
342    Json(state.recording.snapshot().await).into_response()
343}
344
345/// GET /__rdesktop__/agent/recording/poll
346///
347/// Alias used by the browser bridge to discover start/stop commands.
348pub async fn poll_recording(State(state): State<DevServerState>) -> impl IntoResponse {
349    Json(state.recording.snapshot().await).into_response()
350}
351
352/// POST /__rdesktop__/agent/recording/start
353///
354/// Start the single recording, or return the existing session when recording
355/// is already active. This is intentionally idempotent.
356pub 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
392/// POST /__rdesktop__/agent/recording/stop
393///
394/// Stop and finalize the native recorder, or request the browser bridge to
395/// flush and finalize its MediaRecorder. Repeating this call is safe.
396pub 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
411/// POST /__rdesktop__/agent/recording/started
412///
413/// Tell the server which browser MediaRecorder MIME type was selected.
414pub 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
428/// POST /__rdesktop__/agent/recording/chunk
429///
430/// Append one MediaRecorder Blob to the single `.partial` file. Chunks are
431/// serialized by the store so concurrent browser callbacks cannot interleave.
432pub 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
452/// POST /__rdesktop__/agent/recording/complete
453pub 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
467/// POST /__rdesktop__/agent/recording/error
468pub 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
482/// GET /__rdesktop__/agent/recording/file
483pub 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
542/// Simple timestamp helper.
543fn 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
550/// Find elements in HTML matching the query.
551/// This is a simple text-based search, not a full DOM parser.
552fn find_elements(html: &str, query: &ElementQuery) -> Vec<ElementInfo> {
553    let mut elements = vec![];
554
555    if let Some(ref selector) = query.selector {
556        // Simple tag selector matching (e.g., "button", "input", "h1")
557        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            // Extract text content between tags
567            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        // Search for text content
592        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}