1use 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
32pub(crate) const DEFAULT_RECORDING_MAX_DURATION_SECONDS: u64 = 300;
35pub(crate) const MAX_RECORDING_MAX_DURATION_SECONDS: u64 = 3600;
36
37#[derive(Clone)]
39pub struct DevServerState {
40 pub last_dom_snapshot: Arc<RwLock<Option<String>>>,
42
43 pub last_app_state: Arc<RwLock<Option<serde_json::Value>>>,
45
46 pub frontend_dir: PathBuf,
48
49 pub hot_reload: bool,
51
52 pub pending_actions: Arc<Mutex<Vec<agent_api::AgentAction>>>,
54
55 pub reload_generation: Arc<AtomicU64>,
57
58 pub frontend_signature: Arc<RwLock<u64>>,
60
61 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#[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#[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 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
130pub 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 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 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 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 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
525pub struct DevServer {
530 config: DevConfig,
531 frontend_dir: PathBuf,
532 recording: Arc<Mutex<Option<Arc<RecordingStore>>>>,
533}
534
535impl DevServer {
536 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 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 let app = Router::new()
580 .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 .route("/__rdesktop__/health", get(|| async { "ok" }))
642 .route("/__rdesktop__/info", get(dev_info))
644 .route("/__rdesktop__/reload", get(reload_status))
645 .route("/__rdesktop__/bridge.js", get(bridge_script))
646 .route("/__rdesktop__/state", post(update_state))
648 .route("/__rdesktop__/dom", post(update_dom))
649 .layer(CorsLayer::permissive())
651 .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 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 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 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
715async 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
1000async 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
1011async 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}