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("/__rdesktop__/agent/elements", get(agent_api::query_elements))
73            .route("/__rdesktop__/agent/action", post(agent_api::execute_action))
74            .route("/__rdesktop__/agent/state", get(agent_api::get_state))
75            .route("/__rdesktop__/agent/ipc", post(agent_api::send_ipc))
76            .route("/__rdesktop__/agent/screenshot", get(agent_api::take_screenshot))
77            // Health check
78            .route("/__rdesktop__/health", get(|| async { "ok" }))
79            // Dev info
80            .route("/__rdesktop__/info", get(dev_info))
81            // State update from browser
82            .route("/__rdesktop__/state", post(update_state))
83            .route("/__rdesktop__/dom", post(update_dom))
84            // Enable CORS for all routes
85            .layer(CorsLayer::permissive())
86            .with_state(state.clone())
87            // Serve static files as fallback
88            .fallback_service(tower_http::services::ServeDir::new(&self.frontend_dir));
89
90        tracing::info!("rdesktop dev server starting at {}", url);
91        if self.config.agent_mode {
92            tracing::info!("Agent API available at {}/__rdesktop__/agent/", url);
93        }
94
95        let listener = tokio::net::TcpListener::bind(&addr).await?;
96        tracing::info!("Listening on {}", addr);
97
98        // Spawn the server
99        let server_url = url.clone();
100        tokio::spawn(async move {
101            if let Err(e) = axum::serve(listener, app).await {
102                tracing::error!("Server error: {}", e);
103            }
104        });
105
106        // Open browser if configured
107        if self.config.open_browser {
108            if let Err(e) = open::that(&url) {
109                tracing::warn!("Failed to open browser: {}", e);
110            }
111        }
112
113        Ok(server_url)
114    }
115}
116
117/// Dev server info endpoint.
118async fn dev_info() -> Json<serde_json::Value> {
119    Json(serde_json::json!({
120        "framework": "rdesktop",
121        "mode": "development",
122        "version": env!("CARGO_PKG_VERSION"),
123        "agent_api": true,
124        "endpoints": {
125            "dom": "/__rdesktop__/agent/dom",
126            "elements": "/__rdesktop__/agent/elements?selector=<css>",
127            "action": "/__rdesktop__/agent/action",
128            "state": "/__rdesktop__/agent/state",
129            "ipc": "/__rdesktop__/agent/ipc",
130            "screenshot": "/__rdesktop__/agent/screenshot",
131        }
132    }))
133}
134
135/// Update the stored DOM snapshot from the browser.
136async fn update_dom(
137    AxumState(state): AxumState<DevServerState>,
138    Json(body): Json<serde_json::Value>,
139) -> Json<serde_json::Value> {
140    let html = body["html"].as_str().unwrap_or("").to_string();
141    let mut snapshot = state.last_dom_snapshot.write().await;
142    *snapshot = Some(html);
143    Json(serde_json::json!({ "ok": true }))
144}
145
146/// Update the stored app state from the browser.
147async fn update_state(
148    AxumState(state): AxumState<DevServerState>,
149    Json(body): Json<serde_json::Value>,
150) -> Json<serde_json::Value> {
151    let mut app_state = state.last_app_state.write().await;
152    *app_state = Some(body);
153    Json(serde_json::json!({ "ok": true }))
154}