Skip to main content

rdesktop_dev/
server.rs

1//! Development server implementation.
2//!
3//! Serves the frontend as a local web page with hot reload and Agent API.
4//! This is the core of rdesktop's Agent-first development story.
5//!
6//! The dev server does three things:
7//! 1. Serves frontend static files (HTML/CSS/JS)
8//! 2. Injects the rdesktop bridge script for IPC
9//! 3. Provides Agent API endpoints for AI agent interaction
10
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use axum::extract::State as AxumState;
15use axum::routing::{get, post};
16use axum::{Json, Router};
17use tokio::sync::RwLock;
18use tower_http::cors::CorsLayer;
19
20use rdesktop_core::config::DevConfig;
21
22use crate::agent_api;
23
24/// Shared state for the development server.
25#[derive(Clone)]
26pub struct DevServerState {
27    /// The last captured DOM snapshot (for agent queries).
28    pub last_dom_snapshot: Arc<RwLock<Option<String>>>,
29
30    /// The last captured application state.
31    pub last_app_state: Arc<RwLock<Option<serde_json::Value>>>,
32
33    /// The frontend directory path.
34    pub frontend_dir: PathBuf,
35}
36
37/// Development server that serves the app in browser mode.
38///
39/// This is NOT the production renderer. It's a development tool that allows
40/// AI agents (and humans) to interact with the app via a browser.
41pub struct DevServer {
42    config: DevConfig,
43    frontend_dir: PathBuf,
44}
45
46impl DevServer {
47    /// Create a new DevServer.
48    pub fn new(config: DevConfig, frontend_dir: PathBuf) -> Self {
49        Self {
50            config,
51            frontend_dir,
52        }
53    }
54
55    /// Start the development server.
56    ///
57    /// Returns the URL where the server is listening.
58    pub async fn start(&self) -> anyhow::Result<String> {
59        let addr = format!("{}:{}", self.config.host, self.config.port);
60        let url = format!("http://{}", addr);
61
62        let state = DevServerState {
63            last_dom_snapshot: Arc::new(RwLock::new(None)),
64            last_app_state: Arc::new(RwLock::new(None)),
65            frontend_dir: self.frontend_dir.clone(),
66        };
67
68        // Build the router
69        let app = Router::new()
70            // Agent API endpoints
71            .route("/__rdesktop__/agent/dom", get(agent_api::get_dom))
72            .route(
73                "/__rdesktop__/agent/elements",
74                get(agent_api::query_elements),
75            )
76            .route(
77                "/__rdesktop__/agent/action",
78                post(agent_api::execute_action),
79            )
80            .route("/__rdesktop__/agent/state", get(agent_api::get_state))
81            .route("/__rdesktop__/agent/ipc", post(agent_api::send_ipc))
82            .route(
83                "/__rdesktop__/agent/screenshot",
84                get(agent_api::take_screenshot),
85            )
86            // Health check
87            .route("/__rdesktop__/health", get(|| async { "ok" }))
88            // Dev info
89            .route("/__rdesktop__/info", get(dev_info))
90            // State update from browser
91            .route("/__rdesktop__/state", post(update_state))
92            .route("/__rdesktop__/dom", post(update_dom))
93            // Enable CORS for all routes
94            .layer(CorsLayer::permissive())
95            .with_state(state.clone())
96            // Serve static files as fallback
97            .fallback_service(tower_http::services::ServeDir::new(&self.frontend_dir));
98
99        tracing::info!("rdesktop dev server starting at {}", url);
100        if self.config.agent_mode {
101            tracing::info!("Agent API available at {}/__rdesktop__/agent/", url);
102        }
103
104        let listener = tokio::net::TcpListener::bind(&addr).await?;
105        tracing::info!("Listening on {}", addr);
106
107        // Spawn the server
108        let server_url = url.clone();
109        tokio::spawn(async move {
110            if let Err(e) = axum::serve(listener, app).await {
111                tracing::error!("Server error: {}", e);
112            }
113        });
114
115        // Open browser if configured
116        if self.config.open_browser {
117            if let Err(e) = open::that(&url) {
118                tracing::warn!("Failed to open browser: {}", e);
119            }
120        }
121
122        Ok(server_url)
123    }
124}
125
126/// Dev server info endpoint.
127async fn dev_info() -> Json<serde_json::Value> {
128    Json(serde_json::json!({
129        "framework": "rdesktop",
130        "mode": "development",
131        "version": env!("CARGO_PKG_VERSION"),
132        "agent_api": true,
133        "endpoints": {
134            "dom": "/__rdesktop__/agent/dom",
135            "elements": "/__rdesktop__/agent/elements?selector=<css>",
136            "action": "/__rdesktop__/agent/action",
137            "state": "/__rdesktop__/agent/state",
138            "ipc": "/__rdesktop__/agent/ipc",
139            "screenshot": "/__rdesktop__/agent/screenshot",
140        }
141    }))
142}
143
144/// Update the stored DOM snapshot from the browser.
145async fn update_dom(
146    AxumState(state): AxumState<DevServerState>,
147    Json(body): Json<serde_json::Value>,
148) -> Json<serde_json::Value> {
149    let html = body["html"].as_str().unwrap_or("").to_string();
150    let mut snapshot = state.last_dom_snapshot.write().await;
151    *snapshot = Some(html);
152    Json(serde_json::json!({ "ok": true }))
153}
154
155/// Update the stored app state from the browser.
156async fn update_state(
157    AxumState(state): AxumState<DevServerState>,
158    Json(body): Json<serde_json::Value>,
159) -> Json<serde_json::Value> {
160    let mut app_state = state.last_app_state.write().await;
161    *app_state = Some(body);
162    Json(serde_json::json!({ "ok": true }))
163}