1use 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
33pub(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#[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#[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 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#[derive(Clone)]
132pub struct DevServerState {
133 pub last_dom_snapshot: Arc<RwLock<Option<String>>>,
135
136 pub last_app_state: Arc<RwLock<Option<serde_json::Value>>>,
138
139 pub frontend_dir: PathBuf,
141
142 pub hot_reload: bool,
144
145 pub pending_actions: Arc<Mutex<Vec<agent_api::AgentAction>>>,
147
148 pub action_waiters: Arc<Mutex<HashSet<String>>>,
150
151 pub action_results: Arc<Mutex<HashMap<String, agent_api::ActionResult>>>,
153
154 pub action_result_notify: Arc<Notify>,
156
157 pub reload_generation: Arc<AtomicU64>,
159
160 pub frontend_signature: Arc<RwLock<u64>>,
162
163 pub recording: Arc<RecordingStore>,
165
166 pub screenshot_publisher: ScreenshotPublisher,
168
169 pub screenshot_path: Option<PathBuf>,
171
172 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#[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#[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 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
253pub 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 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 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 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 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
648pub 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 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 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 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 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 let app = Router::new()
740 .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 .route("/__rdesktop__/health", get(|| async { "ok" }))
806 .route("/__rdesktop__/info", get(dev_info))
808 .route("/__rdesktop__/reload", get(reload_status))
809 .route("/__rdesktop__/bridge.js", get(bridge_script))
810 .route("/__rdesktop__/state", post(update_state))
812 .route("/__rdesktop__/dom", post(update_dom))
813 .layer(CorsLayer::permissive())
815 .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 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 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 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
879async 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
1192async 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
1203async 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}