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