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::collections::{hash_map::DefaultHasher, HashMap, HashSet};
12use std::hash::{Hash, Hasher};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::UNIX_EPOCH;
17
18use axum::body::Body;
19use axum::extract::{Request, State as AxumState};
20use axum::http::{header, StatusCode};
21use axum::response::Response;
22use axum::routing::{get, post};
23use axum::{Json, Router};
24use tokio::sync::{watch, Mutex, Notify, RwLock};
25use tower_http::cors::CorsLayer;
26
27use rdesktop_core::config::DevConfig;
28use rdesktop_core::ipc::IpcHandler;
29
30use crate::agent_api;
31use crate::native_recorder::NativeRecorder;
32
33/// Recordings are intentionally bounded so a forgotten `stop` cannot keep
34/// producing a large debug artifact forever.
35pub(crate) const DEFAULT_RECORDING_MAX_DURATION_SECONDS: u64 = 300;
36pub(crate) const MAX_RECORDING_MAX_DURATION_SECONDS: u64 = 3600;
37const MAX_SCREENSHOT_BYTES: usize = 16 * 1024 * 1024;
38
39/// A single immutable PNG frame published by a native renderer.
40#[derive(Debug, Clone)]
41pub struct PublishedScreenshot {
42    pub generation: u64,
43    pub png: Vec<u8>,
44}
45
46struct ScreenshotPublisherInner {
47    next_generation: AtomicU64,
48    frames: watch::Sender<Option<PublishedScreenshot>>,
49}
50
51/// Publishes native renderer frames to the Agent API without making the HTTP
52/// server poll a file that may still be written by the renderer.
53#[derive(Clone)]
54pub struct ScreenshotPublisher {
55    inner: Arc<ScreenshotPublisherInner>,
56}
57
58impl ScreenshotPublisher {
59    pub fn new() -> Self {
60        let (frames, _) = watch::channel(None);
61        Self {
62            inner: Arc::new(ScreenshotPublisherInner {
63                next_generation: AtomicU64::new(0),
64                frames,
65            }),
66        }
67    }
68
69    /// Publish a complete PNG frame. Oversized or empty frames are rejected so
70    /// a broken renderer cannot turn the Agent endpoint into an unbounded IPC
71    /// sink.
72    pub fn publish_png(&self, png: &[u8]) {
73        if png.is_empty() || png.len() > MAX_SCREENSHOT_BYTES {
74            tracing::warn!(
75                bytes = png.len(),
76                "rdesktop Agent rejected invalid screenshot frame"
77            );
78            return;
79        }
80        let generation = self
81            .inner
82            .next_generation
83            .fetch_add(1, Ordering::Relaxed)
84            .saturating_add(1);
85        self.inner.frames.send_replace(Some(PublishedScreenshot {
86            generation,
87            png: png.to_vec(),
88        }));
89    }
90
91    pub fn generation(&self) -> u64 {
92        self.inner.next_generation.load(Ordering::Relaxed)
93    }
94
95    pub async fn latest(&self) -> Option<PublishedScreenshot> {
96        self.inner.frames.subscribe().borrow().clone()
97    }
98
99    pub async fn wait_for_next(
100        &self,
101        after_generation: u64,
102        timeout: std::time::Duration,
103    ) -> Option<PublishedScreenshot> {
104        let mut receiver = self.inner.frames.subscribe();
105        let deadline = tokio::time::Instant::now() + timeout;
106        loop {
107            if let Some(frame) = receiver.borrow().clone() {
108                if frame.generation > after_generation {
109                    return Some(frame);
110                }
111            }
112            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
113            if remaining.is_zero() {
114                return None;
115            }
116            match tokio::time::timeout(remaining, receiver.changed()).await {
117                Ok(Ok(())) => {}
118                Ok(Err(_)) | Err(_) => return None,
119            }
120        }
121    }
122}
123
124impl Default for ScreenshotPublisher {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130/// Shared state for the development server.
131#[derive(Clone)]
132pub struct DevServerState {
133    /// The last captured DOM snapshot (for agent queries).
134    pub last_dom_snapshot: Arc<RwLock<Option<String>>>,
135
136    /// The last captured application state.
137    pub last_app_state: Arc<RwLock<Option<serde_json::Value>>>,
138
139    /// The frontend directory path.
140    pub frontend_dir: PathBuf,
141
142    /// Whether frontend file polling is enabled.
143    pub hot_reload: bool,
144
145    /// Shared queue of actions waiting for the browser bridge.
146    pub pending_actions: Arc<Mutex<Vec<agent_api::AgentAction>>>,
147
148    /// Action IDs currently waiting for a bridge execution receipt.
149    pub action_waiters: Arc<Mutex<HashSet<String>>>,
150
151    /// Bridge execution receipts consumed by `?wait=true` callers.
152    pub action_results: Arc<Mutex<HashMap<String, agent_api::ActionResult>>>,
153
154    /// Wakes action callers when the bridge posts a receipt.
155    pub action_result_notify: Arc<Notify>,
156
157    /// Monotonically increasing frontend version used by hot reload.
158    pub reload_generation: Arc<AtomicU64>,
159
160    /// Last observed frontend file signature.
161    pub frontend_signature: Arc<RwLock<u64>>,
162
163    /// The one and only recording session for this dev server.
164    pub recording: Arc<RecordingStore>,
165
166    /// The latest native PNG frame and its generation counter.
167    pub screenshot_publisher: ScreenshotPublisher,
168
169    /// Optional compatibility path used by hosts that also persist frames.
170    pub screenshot_path: Option<PathBuf>,
171
172    /// Optional host IPC handler for the native Agent bridge.
173    pub ipc_handler: Option<Arc<dyn IpcHandler>>,
174}
175
176impl DevServerState {
177    fn new(
178        frontend_dir: PathBuf,
179        hot_reload: bool,
180        screenshot_publisher: ScreenshotPublisher,
181        screenshot_path: Option<PathBuf>,
182        ipc_handler: Option<Arc<dyn IpcHandler>>,
183    ) -> Self {
184        let recording_path = frontend_dir
185            .parent()
186            .unwrap_or(&frontend_dir)
187            .join(".rdesktop")
188            .join("recording.mp4");
189
190        Self {
191            last_dom_snapshot: Arc::new(RwLock::new(None)),
192            last_app_state: Arc::new(RwLock::new(None)),
193            frontend_dir,
194            hot_reload,
195            pending_actions: Arc::new(Mutex::new(Vec::new())),
196            action_waiters: Arc::new(Mutex::new(HashSet::new())),
197            action_results: Arc::new(Mutex::new(HashMap::new())),
198            action_result_notify: Arc::new(Notify::new()),
199            reload_generation: Arc::new(AtomicU64::new(0)),
200            frontend_signature: Arc::new(RwLock::new(0)),
201            recording: Arc::new(if cfg!(windows) {
202                RecordingStore::new_native(recording_path)
203            } else {
204                RecordingStore::new(recording_path)
205            }),
206            screenshot_publisher,
207            screenshot_path,
208            ipc_handler,
209        }
210    }
211}
212
213/// Lifecycle of the single development recording.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
215#[serde(rename_all = "snake_case")]
216pub enum RecordingStatus {
217    Idle,
218    Recording,
219    StopRequested,
220    Finalizing,
221    Completed,
222    Failed,
223}
224
225/// A stable snapshot returned to agents.
226#[derive(Debug, Clone, serde::Serialize)]
227pub struct RecordingSnapshot {
228    pub status: RecordingStatus,
229    pub session_id: Option<String>,
230    pub path: String,
231    pub download_url: String,
232    pub mime_type: Option<String>,
233    /// True when the dev server is capturing the native desktop directly.
234    /// False means the browser bridge owns MediaRecorder capture.
235    pub native: bool,
236    pub bytes: u64,
237    pub error: Option<String>,
238    pub started_at: Option<String>,
239    pub finished_at: Option<String>,
240}
241
242#[derive(Debug)]
243struct RecordingData {
244    status: RecordingStatus,
245    session_id: Option<String>,
246    mime_type: Option<String>,
247    bytes: u64,
248    error: Option<String>,
249    started_at: Option<String>,
250    finished_at: Option<String>,
251}
252
253/// Owns the single recording file and its state.
254///
255/// The output stem is deliberately fixed. This makes recording start/stop
256/// idempotent and prevents a dev session from accumulating timestamped files.
257/// Windows native capture uses the fixed MP4 path; browser fallback follows
258/// the browser's native MIME type.
259pub struct RecordingStore {
260    output_stem: PathBuf,
261    partial_path: PathBuf,
262    native_partial_path: PathBuf,
263    data: Mutex<RecordingData>,
264    file: Mutex<Option<tokio::fs::File>>,
265    native: Option<Arc<NativeRecorder>>,
266    next_id: AtomicU64,
267}
268
269impl RecordingStore {
270    fn new(output_path: PathBuf) -> Self {
271        Self::with_native(output_path, None)
272    }
273
274    fn new_native(output_path: PathBuf) -> Self {
275        Self::with_native(output_path, Some(Arc::new(NativeRecorder::new())))
276    }
277
278    fn with_native(output_path: PathBuf, native: Option<Arc<NativeRecorder>>) -> Self {
279        let output_stem = output_path.with_extension("");
280        let partial_path = output_stem.with_extension("partial");
281        let native_partial_path = output_stem.with_extension("partial.mp4");
282        Self {
283            output_stem,
284            partial_path,
285            native_partial_path,
286            data: Mutex::new(RecordingData {
287                status: RecordingStatus::Idle,
288                session_id: None,
289                mime_type: None,
290                bytes: 0,
291                error: None,
292                started_at: None,
293                finished_at: None,
294            }),
295            file: Mutex::new(None),
296            native,
297            next_id: AtomicU64::new(1),
298        }
299    }
300
301    async fn prepare(&self) -> anyhow::Result<()> {
302        if let Some(parent) = self.output_stem.parent() {
303            tokio::fs::create_dir_all(parent).await?;
304        }
305        // These are transient files from a previous interrupted session. The
306        // finalized fixed output is intentionally kept for agent inspection.
307        tokio::fs::remove_file(&self.partial_path).await.ok();
308        tokio::fs::remove_file(&self.native_partial_path).await.ok();
309        Ok(())
310    }
311
312    fn final_path(&self, mime_type: Option<&str>) -> PathBuf {
313        let extension = if mime_type
314            .map(|mime_type| mime_type.starts_with("video/mp4"))
315            .unwrap_or(false)
316        {
317            "mp4"
318        } else {
319            "webm"
320        };
321        self.output_stem.with_extension(extension)
322    }
323
324    pub(crate) async fn snapshot(&self) -> RecordingSnapshot {
325        let data = self.data.lock().await;
326        RecordingSnapshot {
327            status: data.status,
328            session_id: data.session_id.clone(),
329            path: self
330                .final_path(data.mime_type.as_deref())
331                .display()
332                .to_string(),
333            download_url: "/__rdesktop__/agent/recording/file".to_string(),
334            mime_type: data.mime_type.clone(),
335            native: self.native.is_some(),
336            bytes: data.bytes,
337            error: data.error.clone(),
338            started_at: data.started_at.clone(),
339            finished_at: data.finished_at.clone(),
340        }
341    }
342
343    pub(crate) async fn start_with_options(
344        &self,
345        fps: u32,
346        max_duration: std::time::Duration,
347    ) -> anyhow::Result<(RecordingSnapshot, bool)> {
348        let mut data = self.data.lock().await;
349        if matches!(
350            data.status,
351            RecordingStatus::Recording
352                | RecordingStatus::StopRequested
353                | RecordingStatus::Finalizing
354        ) {
355            return Ok((self.snapshot_from_data(&data), true));
356        }
357
358        self.prepare().await?;
359        tokio::fs::remove_file(self.final_path(Some("video/mp4")))
360            .await
361            .ok();
362        tokio::fs::remove_file(self.final_path(Some("video/webm")))
363            .await
364            .ok();
365
366        let id = format!(
367            "{}-{}",
368            unix_millis(),
369            self.next_id.fetch_add(1, Ordering::Relaxed)
370        );
371        let native = self.native.clone();
372        data.status = RecordingStatus::Recording;
373        data.session_id = Some(id);
374        data.mime_type = native.as_ref().map(|_| "video/mp4".to_string());
375        data.bytes = 0;
376        data.error = None;
377        data.started_at = Some(timestamp());
378        data.finished_at = None;
379
380        if let Some(native) = native {
381            // Media Foundation selects its MP4 sink from the filename
382            // extension, so the transient native path also ends in `.mp4`.
383            // It is renamed to the fixed output only after Finalize succeeds.
384            if let Err(error) = native
385                .start(self.native_partial_path.clone(), fps.max(1), max_duration)
386                .await
387            {
388                data.status = RecordingStatus::Failed;
389                data.error = Some(error.to_string());
390                data.finished_at = Some(timestamp());
391                tokio::fs::remove_file(&self.native_partial_path).await.ok();
392                return Err(error);
393            }
394        } else {
395            let mut file = self.file.lock().await;
396            *file = Some(
397                tokio::fs::OpenOptions::new()
398                    .create(true)
399                    .truncate(true)
400                    .write(true)
401                    .open(&self.partial_path)
402                    .await?,
403            );
404        }
405
406        let snapshot = self.snapshot_from_data(&data);
407        Ok((snapshot, false))
408    }
409
410    fn snapshot_from_data(&self, data: &RecordingData) -> RecordingSnapshot {
411        RecordingSnapshot {
412            status: data.status,
413            session_id: data.session_id.clone(),
414            path: self
415                .final_path(data.mime_type.as_deref())
416                .display()
417                .to_string(),
418            download_url: "/__rdesktop__/agent/recording/file".to_string(),
419            mime_type: data.mime_type.clone(),
420            native: self.native.is_some(),
421            bytes: data.bytes,
422            error: data.error.clone(),
423            started_at: data.started_at.clone(),
424            finished_at: data.finished_at.clone(),
425        }
426    }
427
428    pub(crate) async fn request_stop(
429        &self,
430        session_id: Option<&str>,
431    ) -> anyhow::Result<RecordingSnapshot> {
432        let mut data = self.data.lock().await;
433        if let Some(expected) = session_id {
434            if data.session_id.as_deref() != Some(expected) {
435                anyhow::bail!("recording session does not match the active session");
436            }
437        }
438        if data.status == RecordingStatus::Recording {
439            data.status = RecordingStatus::StopRequested;
440        }
441        Ok(self.snapshot_from_data(&data))
442    }
443
444    /// Stop the recording. Native capture can finalize synchronously because
445    /// the encoder is owned by the server; browser capture still needs the
446    /// bridge to flush its MediaRecorder chunks.
447    pub(crate) async fn stop(&self, session_id: Option<&str>) -> anyhow::Result<RecordingSnapshot> {
448        let Some(native) = self.native.clone() else {
449            return self.request_stop(session_id).await;
450        };
451
452        {
453            let mut data = self.data.lock().await;
454            if let Some(expected) = session_id {
455                if data.session_id.as_deref() != Some(expected) {
456                    anyhow::bail!("recording session does not match the active session");
457                }
458            }
459            if matches!(
460                data.status,
461                RecordingStatus::Idle | RecordingStatus::Completed | RecordingStatus::Failed
462            ) {
463                return Ok(self.snapshot_from_data(&data));
464            }
465            if data.status == RecordingStatus::Finalizing {
466                return Ok(self.snapshot_from_data(&data));
467            }
468            data.status = RecordingStatus::Finalizing;
469        }
470
471        let result = match native.stop().await {
472            Ok(_) => {
473                let final_path = self.final_path(Some("video/mp4"));
474                tokio::fs::remove_file(&final_path).await.ok();
475                tokio::fs::remove_file(self.final_path(Some("video/webm")))
476                    .await
477                    .ok();
478                tokio::fs::rename(&self.native_partial_path, &final_path).await?;
479                Ok(tokio::fs::metadata(&final_path).await?.len())
480            }
481            Err(error) => Err(error),
482        };
483
484        let mut data = self.data.lock().await;
485        match result {
486            Ok(bytes) => {
487                data.status = RecordingStatus::Completed;
488                data.bytes = bytes;
489                data.error = None;
490            }
491            Err(error) => {
492                data.status = RecordingStatus::Failed;
493                data.error = Some(error.to_string());
494                tokio::fs::remove_file(&self.native_partial_path).await.ok();
495                tokio::fs::remove_file(self.final_path(Some("video/mp4")))
496                    .await
497                    .ok();
498            }
499        }
500        data.finished_at = Some(timestamp());
501        Ok(self.snapshot_from_data(&data))
502    }
503
504    pub(crate) async fn mark_started(
505        &self,
506        session_id: &str,
507        mime_type: &str,
508    ) -> anyhow::Result<()> {
509        if self.native.is_some() {
510            anyhow::bail!("native recording does not accept browser metadata")
511        }
512        let mut data = self.data.lock().await;
513        if data.session_id.as_deref() != Some(session_id) {
514            anyhow::bail!("recording session does not match the active session");
515        }
516        if matches!(
517            data.status,
518            RecordingStatus::Recording | RecordingStatus::StopRequested
519        ) {
520            data.mime_type = Some(mime_type.to_string());
521            return Ok(());
522        }
523        anyhow::bail!("recording is not accepting browser metadata")
524    }
525
526    pub(crate) async fn append_chunk(&self, session_id: &str, chunk: &[u8]) -> anyhow::Result<u64> {
527        if self.native.is_some() {
528            anyhow::bail!("native recording does not accept browser media chunks")
529        }
530        {
531            let data = self.data.lock().await;
532            if data.session_id.as_deref() != Some(session_id) {
533                anyhow::bail!("recording session does not match the active session");
534            }
535            if !matches!(
536                data.status,
537                RecordingStatus::Recording | RecordingStatus::StopRequested
538            ) {
539                anyhow::bail!("recording is not accepting media chunks");
540            }
541        }
542
543        use tokio::io::AsyncWriteExt;
544        let mut file_guard = self.file.lock().await;
545        let file = file_guard
546            .as_mut()
547            .ok_or_else(|| anyhow::anyhow!("recording file is not open"))?;
548        file.write_all(chunk).await?;
549        file.flush().await?;
550        drop(file_guard);
551
552        let mut data = self.data.lock().await;
553        data.bytes = data.bytes.saturating_add(chunk.len() as u64);
554        Ok(data.bytes)
555    }
556
557    pub(crate) async fn complete(
558        &self,
559        session_id: &str,
560        mime_type: Option<&str>,
561    ) -> anyhow::Result<RecordingSnapshot> {
562        if self.native.is_some() {
563            anyhow::bail!("native recording is finalized by the server stop operation")
564        }
565        let mime_type = {
566            let mut data = self.data.lock().await;
567            if data.session_id.as_deref() != Some(session_id) {
568                anyhow::bail!("recording session does not match the active session");
569            }
570            if data.status == RecordingStatus::Completed {
571                return Ok(self.snapshot_from_data(&data));
572            }
573            if data.status == RecordingStatus::Finalizing {
574                return Ok(self.snapshot_from_data(&data));
575            }
576            if let Some(mime_type) = mime_type {
577                data.mime_type = Some(mime_type.to_string());
578            }
579            data.status = RecordingStatus::Finalizing;
580            data.mime_type.clone().unwrap_or_default()
581        };
582
583        // Close the file before rename/conversion. This is required on Windows.
584        self.file.lock().await.take();
585        let final_path = self.final_path(Some(&mime_type));
586        let result = {
587            tokio::fs::remove_file(self.final_path(Some("video/mp4")))
588                .await
589                .ok();
590            tokio::fs::remove_file(self.final_path(Some("video/webm")))
591                .await
592                .ok();
593            tokio::fs::rename(&self.partial_path, &final_path)
594                .await
595                .map_err(anyhow::Error::from)
596        };
597
598        let mut data = self.data.lock().await;
599        match result {
600            Ok(()) => {
601                data.status = RecordingStatus::Completed;
602                data.finished_at = Some(timestamp());
603                data.error = None;
604            }
605            Err(error) => {
606                data.status = RecordingStatus::Failed;
607                data.finished_at = Some(timestamp());
608                data.error = Some(error.to_string());
609                tokio::fs::remove_file(&self.partial_path).await.ok();
610            }
611        }
612        Ok(self.snapshot_from_data(&data))
613    }
614
615    pub(crate) async fn fail(
616        &self,
617        session_id: &str,
618        error: String,
619    ) -> anyhow::Result<RecordingSnapshot> {
620        self.file.lock().await.take();
621        let mut data = self.data.lock().await;
622        if data.session_id.as_deref() != Some(session_id) {
623            anyhow::bail!("recording session does not match the active session");
624        }
625        if matches!(
626            data.status,
627            RecordingStatus::Completed | RecordingStatus::Failed
628        ) {
629            return Ok(self.snapshot_from_data(&data));
630        }
631        data.status = RecordingStatus::Failed;
632        data.error = Some(error);
633        data.finished_at = Some(timestamp());
634        drop(data);
635        tokio::fs::remove_file(&self.partial_path).await.ok();
636        tokio::fs::remove_file(&self.native_partial_path).await.ok();
637        tokio::fs::remove_file(self.final_path(Some("video/mp4")))
638            .await
639            .ok();
640        tokio::fs::remove_file(self.final_path(Some("video/webm")))
641            .await
642            .ok();
643        let data = self.data.lock().await;
644        Ok(self.snapshot_from_data(&data))
645    }
646}
647
648/// Development server that serves the app in browser mode.
649///
650/// This is NOT the production renderer. It's a development tool that allows
651/// AI agents (and humans) to interact with the app via a browser.
652pub struct DevServer {
653    config: DevConfig,
654    frontend_dir: PathBuf,
655    recording: Arc<Mutex<Option<Arc<RecordingStore>>>>,
656    ipc_handler: Option<Arc<dyn IpcHandler>>,
657    screenshot_path: Option<PathBuf>,
658    screenshot_publisher: ScreenshotPublisher,
659}
660
661impl DevServer {
662    /// Create a new DevServer.
663    pub fn new(config: DevConfig, frontend_dir: PathBuf) -> Self {
664        Self {
665            config,
666            frontend_dir,
667            recording: Arc::new(Mutex::new(None)),
668            ipc_handler: None,
669            screenshot_path: None,
670            screenshot_publisher: ScreenshotPublisher::new(),
671        }
672    }
673
674    /// Create a server whose Agent IPC endpoint forwards to the native host.
675    pub fn new_with_handler(
676        config: DevConfig,
677        frontend_dir: PathBuf,
678        handler: Arc<dyn IpcHandler>,
679    ) -> Self {
680        Self {
681            config,
682            frontend_dir,
683            recording: Arc::new(Mutex::new(None)),
684            ipc_handler: Some(handler),
685            screenshot_path: None,
686            screenshot_publisher: ScreenshotPublisher::new(),
687        }
688    }
689
690    /// Keep a stable on-disk copy for humans and external image viewers. The
691    /// Agent endpoint itself serves the in-memory published frame.
692    pub fn with_screenshot_path(mut self, path: PathBuf) -> Self {
693        self.screenshot_path = Some(path);
694        self
695    }
696
697    pub fn screenshot_publisher(&self) -> ScreenshotPublisher {
698        self.screenshot_publisher.clone()
699    }
700
701    /// Start the development server.
702    ///
703    /// Returns the URL where the server is listening.
704    pub async fn start(&self) -> anyhow::Result<String> {
705        let addr = format!("{}:{}", self.config.host, self.config.port);
706        let url = format!("http://{}", addr);
707
708        let state = DevServerState::new(
709            self.frontend_dir.clone(),
710            self.config.hot_reload,
711            self.screenshot_publisher.clone(),
712            self.screenshot_path.clone(),
713            self.ipc_handler.clone(),
714        );
715        *self.recording.lock().await = Some(state.recording.clone());
716        state.recording.prepare().await?;
717
718        if self.config.hot_reload {
719            let signature = frontend_signature(&state.frontend_dir);
720            *state.frontend_signature.write().await = signature;
721            let watch_state = state.clone();
722            tokio::spawn(async move {
723                let mut interval = tokio::time::interval(std::time::Duration::from_millis(300));
724                loop {
725                    interval.tick().await;
726                    let current = frontend_signature(&watch_state.frontend_dir);
727                    let mut previous = watch_state.frontend_signature.write().await;
728                    if *previous != current {
729                        *previous = current;
730                        watch_state
731                            .reload_generation
732                            .fetch_add(1, Ordering::Relaxed);
733                    }
734                }
735            });
736        }
737
738        // Build the router
739        let app = Router::new()
740            // Agent API endpoints
741            .route("/__rdesktop__/agent/dom", get(agent_api::get_dom))
742            .route(
743                "/__rdesktop__/agent/elements",
744                get(agent_api::query_elements),
745            )
746            .route(
747                "/__rdesktop__/agent/action",
748                post(agent_api::execute_action),
749            )
750            .route("/__rdesktop__/agent/state", get(agent_api::get_state))
751            .route("/__rdesktop__/agent/ipc", post(agent_api::send_ipc))
752            .route(
753                "/__rdesktop__/agent/screenshot",
754                get(agent_api::take_screenshot),
755            )
756            .route(
757                "/__rdesktop__/agent/recording",
758                get(agent_api::get_recording),
759            )
760            .route(
761                "/__rdesktop__/agent/recording/start",
762                post(agent_api::start_recording),
763            )
764            .route(
765                "/__rdesktop__/agent/recording/stop",
766                post(agent_api::stop_recording),
767            )
768            .route(
769                "/__rdesktop__/agent/recording/status",
770                get(agent_api::get_recording),
771            )
772            .route(
773                "/__rdesktop__/agent/recording/poll",
774                get(agent_api::poll_recording),
775            )
776            .route(
777                "/__rdesktop__/agent/recording/started",
778                post(agent_api::recording_started),
779            )
780            .route(
781                "/__rdesktop__/agent/recording/chunk",
782                post(agent_api::recording_chunk),
783            )
784            .route(
785                "/__rdesktop__/agent/recording/complete",
786                post(agent_api::recording_complete),
787            )
788            .route(
789                "/__rdesktop__/agent/recording/error",
790                post(agent_api::recording_error),
791            )
792            .route(
793                "/__rdesktop__/agent/recording/file",
794                get(agent_api::recording_file),
795            )
796            .route(
797                "/__rdesktop__/agent/action/pending",
798                get(agent_api::pending_actions),
799            )
800            .route(
801                "/__rdesktop__/agent/action/result",
802                post(agent_api::report_action_result),
803            )
804            // Health check
805            .route("/__rdesktop__/health", get(|| async { "ok" }))
806            // Dev info
807            .route("/__rdesktop__/info", get(dev_info))
808            .route("/__rdesktop__/reload", get(reload_status))
809            .route("/__rdesktop__/bridge.js", get(bridge_script))
810            // State update from browser
811            .route("/__rdesktop__/state", post(update_state))
812            .route("/__rdesktop__/dom", post(update_dom))
813            // Enable CORS for all routes
814            .layer(CorsLayer::permissive())
815            // Serve static files and inject the bridge into HTML documents.
816            .fallback(serve_frontend)
817            .with_state(state.clone());
818
819        tracing::info!("rdesktop dev server starting at {}", url);
820        if self.config.agent_mode {
821            tracing::info!("Agent API available at {}/__rdesktop__/agent/", url);
822        }
823
824        let listener = tokio::net::TcpListener::bind(&addr).await?;
825        tracing::info!("Listening on {}", addr);
826
827        // Spawn the server
828        let server_url = url.clone();
829        tokio::spawn(async move {
830            if let Err(e) = axum::serve(listener, app).await {
831                tracing::error!("Server error: {}", e);
832            }
833        });
834
835        // Open browser if configured
836        if self.config.open_browser {
837            if let Err(e) = open::that(&url) {
838                tracing::warn!("Failed to open browser: {}", e);
839            }
840        }
841
842        Ok(server_url)
843    }
844
845    /// Finalize or discard an active debug recording before the dev process
846    /// exits. Native MP4 recording is finalized; browser fallback recordings
847    /// are marked failed so their partial chunks do not remain as garbage.
848    pub async fn shutdown(&self) -> anyhow::Result<()> {
849        let recording = self.recording.lock().await.take();
850        let Some(recording) = recording else {
851            return Ok(());
852        };
853
854        let snapshot = recording.snapshot().await;
855        let Some(session_id) = snapshot.session_id else {
856            return Ok(());
857        };
858        match snapshot.status {
859            RecordingStatus::Recording
860            | RecordingStatus::StopRequested
861            | RecordingStatus::Finalizing => {
862                if snapshot.native {
863                    recording.stop(Some(&session_id)).await?;
864                } else {
865                    recording
866                        .fail(
867                            &session_id,
868                            "dev server shut down before browser recording finalized".to_string(),
869                        )
870                        .await?;
871                }
872            }
873            RecordingStatus::Idle | RecordingStatus::Completed | RecordingStatus::Failed => {}
874        }
875        Ok(())
876    }
877}
878
879/// Dev server info endpoint.
880async fn dev_info() -> Json<serde_json::Value> {
881    Json(serde_json::json!({
882        "framework": "rdesktop",
883        "mode": "development",
884        "version": env!("CARGO_PKG_VERSION"),
885        "agent_api": true,
886        "endpoints": {
887            "dom": "/__rdesktop__/agent/dom",
888            "elements": "/__rdesktop__/agent/elements?selector=<css>",
889            "action": "/__rdesktop__/agent/action",
890            "action_result": "/__rdesktop__/agent/action/result",
891            "state": "/__rdesktop__/agent/state",
892            "ipc": "/__rdesktop__/agent/ipc",
893            "screenshot": "/__rdesktop__/agent/screenshot",
894            "recording": "/__rdesktop__/agent/recording",
895            "recording_start": "/__rdesktop__/agent/recording/start",
896            "recording_stop": "/__rdesktop__/agent/recording/stop",
897            "recording_file": "/__rdesktop__/agent/recording/file",
898        }
899    }))
900}
901
902async fn reload_status(AxumState(state): AxumState<DevServerState>) -> Json<serde_json::Value> {
903    Json(serde_json::json!({
904        "generation": state.reload_generation.load(Ordering::Relaxed),
905        "enabled": state.hot_reload,
906    }))
907}
908
909async fn bridge_script() -> Response {
910    Response::builder()
911        .status(StatusCode::OK)
912        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
913        .header(header::CACHE_CONTROL, "no-store")
914        .body(Body::from(include_str!("../assets/bridge.js")))
915        .expect("static bridge response is valid")
916}
917
918async fn serve_frontend(AxumState(state): AxumState<DevServerState>, request: Request) -> Response {
919    let request_path = request.uri().path();
920    let relative = request_path.trim_start_matches('/');
921    if relative.split('/').any(|part| part == "..") || relative.contains('\\') {
922        return response_text(StatusCode::BAD_REQUEST, "invalid frontend path");
923    }
924
925    let mut file_path = state.frontend_dir.join(if relative.is_empty() {
926        "index.html"
927    } else {
928        relative
929    });
930    if tokio::fs::metadata(&file_path)
931        .await
932        .map(|metadata| metadata.is_dir())
933        .unwrap_or(false)
934    {
935        file_path = file_path.join("index.html");
936    }
937
938    let bytes = match tokio::fs::read(&file_path).await {
939        Ok(bytes) => bytes,
940        Err(_) => return response_text(StatusCode::NOT_FOUND, "frontend file not found"),
941    };
942    let is_html = file_path
943        .extension()
944        .and_then(|extension| extension.to_str())
945        .map(|extension| extension.eq_ignore_ascii_case("html"))
946        .unwrap_or(false);
947    let body = if is_html {
948        let mut html = String::from_utf8_lossy(&bytes).into_owned();
949        if !html.contains("/__rdesktop__/bridge.js") {
950            let bridge = "<script src=\"/__rdesktop__/bridge.js\"></script>";
951            if let Some(index) = html.to_ascii_lowercase().find("</head>") {
952                html.insert_str(index, bridge);
953            } else {
954                html.insert_str(0, bridge);
955            }
956        }
957        Body::from(html)
958    } else {
959        Body::from(bytes)
960    };
961
962    Response::builder()
963        .status(StatusCode::OK)
964        .header(header::CONTENT_TYPE, content_type(&file_path))
965        .body(body)
966        .expect("frontend response is valid")
967}
968
969fn response_text(status: StatusCode, text: &str) -> Response {
970    Response::builder()
971        .status(status)
972        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
973        .body(Body::from(text.to_string()))
974        .expect("text response is valid")
975}
976
977fn content_type(path: &Path) -> &'static str {
978    match path
979        .extension()
980        .and_then(|extension| extension.to_str())
981        .unwrap_or_default()
982    {
983        "html" => "text/html; charset=utf-8",
984        "js" => "text/javascript; charset=utf-8",
985        "css" => "text/css; charset=utf-8",
986        "json" => "application/json",
987        "svg" => "image/svg+xml",
988        "png" => "image/png",
989        "jpg" | "jpeg" => "image/jpeg",
990        "webp" => "image/webp",
991        "wasm" => "application/wasm",
992        _ => "application/octet-stream",
993    }
994}
995
996fn frontend_signature(root: &Path) -> u64 {
997    let mut entries = Vec::new();
998    collect_frontend_files(root, &mut entries);
999    entries.sort();
1000    let mut hasher = DefaultHasher::new();
1001    entries.hash(&mut hasher);
1002    hasher.finish()
1003}
1004
1005fn collect_frontend_files(root: &Path, entries: &mut Vec<(String, u64, u64)>) {
1006    let Ok(read_dir) = std::fs::read_dir(root) else {
1007        return;
1008    };
1009    for entry in read_dir.flatten() {
1010        let path = entry.path();
1011        let Ok(metadata) = entry.metadata() else {
1012            continue;
1013        };
1014        if metadata.is_dir() {
1015            collect_frontend_files(&path, entries);
1016        } else if metadata.is_file() {
1017            let modified = metadata
1018                .modified()
1019                .ok()
1020                .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
1021                .map(|duration| duration.as_nanos() as u64)
1022                .unwrap_or_default();
1023            entries.push((path.display().to_string(), modified, metadata.len()));
1024        }
1025    }
1026}
1027
1028fn unix_millis() -> u128 {
1029    std::time::SystemTime::now()
1030        .duration_since(UNIX_EPOCH)
1031        .unwrap_or_default()
1032        .as_millis()
1033}
1034
1035fn timestamp() -> String {
1036    std::time::SystemTime::now()
1037        .duration_since(UNIX_EPOCH)
1038        .unwrap_or_default()
1039        .as_secs()
1040        .to_string()
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    fn test_path(name: &str) -> PathBuf {
1048        std::env::temp_dir().join(format!(
1049            "rdesktop-dev-{}-{}-{}",
1050            name,
1051            std::process::id(),
1052            unix_millis()
1053        ))
1054    }
1055
1056    #[tokio::test]
1057    async fn recording_start_is_singleton_and_mp4_stop_is_idempotent() {
1058        let root = test_path("recording");
1059        let output = root.join("recording.mp4");
1060        let store = RecordingStore::new(output.clone());
1061
1062        let (first, reused) = store
1063            .start_with_options(
1064                30,
1065                std::time::Duration::from_secs(DEFAULT_RECORDING_MAX_DURATION_SECONDS),
1066            )
1067            .await
1068            .expect("start recording");
1069        assert!(!reused);
1070        let (second, reused) = store
1071            .start_with_options(
1072                30,
1073                std::time::Duration::from_secs(DEFAULT_RECORDING_MAX_DURATION_SECONDS),
1074            )
1075            .await
1076            .expect("reuse recording");
1077        assert!(reused);
1078        assert_eq!(first.session_id, second.session_id);
1079
1080        let session_id = first.session_id.as_deref().expect("session id");
1081        store
1082            .mark_started(session_id, "video/mp4")
1083            .await
1084            .expect("mark mime");
1085        store
1086            .append_chunk(session_id, b"fake-mp4")
1087            .await
1088            .expect("append chunk");
1089        store
1090            .request_stop(Some(session_id))
1091            .await
1092            .expect("request stop");
1093        let completed = store
1094            .complete(session_id, Some("video/mp4"))
1095            .await
1096            .expect("complete recording");
1097        assert_eq!(completed.status, RecordingStatus::Completed);
1098        assert_eq!(
1099            tokio::fs::read(&output).await.expect("read mp4"),
1100            b"fake-mp4"
1101        );
1102
1103        let repeated = store
1104            .complete(session_id, Some("video/mp4"))
1105            .await
1106            .expect("repeat complete");
1107        assert_eq!(repeated.status, RecordingStatus::Completed);
1108        assert_eq!(repeated.path, completed.path);
1109
1110        tokio::fs::remove_dir_all(root)
1111            .await
1112            .expect("cleanup test files");
1113    }
1114
1115    #[tokio::test]
1116    async fn concurrent_starts_share_one_session() {
1117        let root = test_path("concurrent");
1118        let store = Arc::new(RecordingStore::new(root.join("recording.mp4")));
1119        let first_store = store.clone();
1120        let second_store = store.clone();
1121        let duration = std::time::Duration::from_secs(DEFAULT_RECORDING_MAX_DURATION_SECONDS);
1122        let (first, second) = tokio::join!(
1123            first_store.start_with_options(30, duration),
1124            second_store.start_with_options(30, duration)
1125        );
1126        let first = first.expect("first start");
1127        let second = second.expect("second start");
1128        assert_ne!(first.1, second.1);
1129        assert_eq!(first.0.session_id, second.0.session_id);
1130        drop(store);
1131        tokio::fs::remove_dir_all(root)
1132            .await
1133            .expect("cleanup test files");
1134    }
1135
1136    #[tokio::test]
1137    async fn stale_transient_files_are_removed_before_a_new_session() {
1138        let root = test_path("stale-transients");
1139        tokio::fs::create_dir_all(&root)
1140            .await
1141            .expect("create test directory");
1142        let store = RecordingStore::new(root.join("recording.mp4"));
1143        tokio::fs::write(&store.partial_path, b"stale browser bytes")
1144            .await
1145            .expect("write browser transient");
1146        tokio::fs::write(&store.native_partial_path, b"stale native bytes")
1147            .await
1148            .expect("write native transient");
1149
1150        store.prepare().await.expect("prepare recording directory");
1151
1152        assert!(!tokio::fs::try_exists(&store.partial_path)
1153            .await
1154            .expect("check browser transient"));
1155        assert!(!tokio::fs::try_exists(&store.native_partial_path)
1156            .await
1157            .expect("check native transient"));
1158
1159        tokio::fs::remove_dir_all(root)
1160            .await
1161            .expect("cleanup test files");
1162    }
1163
1164    #[tokio::test]
1165    async fn screenshot_publisher_returns_complete_frames_and_waits_for_new_generation() {
1166        let publisher = ScreenshotPublisher::new();
1167        assert_eq!(publisher.generation(), 0);
1168        assert!(publisher.latest().await.is_none());
1169
1170        let waiter = publisher.clone();
1171        let pending = tokio::spawn(async move {
1172            waiter
1173                .wait_for_next(0, std::time::Duration::from_secs(1))
1174                .await
1175        });
1176        tokio::task::yield_now().await;
1177        publisher.publish_png(b"complete-png-frame");
1178
1179        let frame = pending
1180            .await
1181            .expect("screenshot waiter")
1182            .expect("new frame");
1183        assert_eq!(frame.generation, 1);
1184        assert_eq!(frame.png, b"complete-png-frame");
1185        assert_eq!(
1186            publisher.latest().await.expect("latest frame").generation,
1187            1
1188        );
1189    }
1190}
1191
1192/// Update the stored DOM snapshot from the browser.
1193async fn update_dom(
1194    AxumState(state): AxumState<DevServerState>,
1195    Json(body): Json<serde_json::Value>,
1196) -> Json<serde_json::Value> {
1197    let html = body["html"].as_str().unwrap_or("").to_string();
1198    let mut snapshot = state.last_dom_snapshot.write().await;
1199    *snapshot = Some(html);
1200    Json(serde_json::json!({ "ok": true }))
1201}
1202
1203/// Update the stored app state from the browser.
1204async fn update_state(
1205    AxumState(state): AxumState<DevServerState>,
1206    Json(body): Json<serde_json::Value>,
1207) -> Json<serde_json::Value> {
1208    let mut app_state = state.last_app_state.write().await;
1209    *app_state = Some(body);
1210    Json(serde_json::json!({ "ok": true }))
1211}