1use std::collections::HashMap;
2use std::convert::Infallible;
3use std::net::{IpAddr, SocketAddr};
4use std::sync::{Arc, Mutex, RwLock};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, anyhow};
9use axum::extract::{ConnectInfo, Path, Query, Request, State};
10use axum::http::header::{AUTHORIZATION, HOST, ORIGIN};
11use axum::http::{HeaderMap, HeaderName, StatusCode};
12use axum::middleware::{self, Next};
13use axum::response::sse::{Event, KeepAlive, Sse};
14use axum::response::{Html, IntoResponse, Response};
15use axum::routing::{get, post};
16use axum::{Json, Router};
17use dais_core::bus::CommandSender;
18use dais_core::commands::Command;
19use dais_core::config::RemoteConfig;
20use dais_core::keybindings::Action;
21use dais_core::state::{PresentationState, TimerPhase};
22use dais_document::page::RenderSize;
23use dais_document::render_pipeline::FALLBACK_RENDER_SIZE;
24use dais_document::source::DocumentSource;
25use dais_document::typst_renderer::render_text_box_svg;
26use image::codecs::png::PngEncoder;
27use image::{ColorType, ImageEncoder};
28use serde::{Deserialize, Serialize};
29use tokio::sync::oneshot;
30use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
31
32const SSE_INTERVAL: Duration = Duration::from_millis(500);
33const COMMAND_APPLY_TIMEOUT: Duration = Duration::from_millis(250);
34const COMMAND_APPLY_POLL: Duration = Duration::from_millis(10);
35const REMOTE_SLIDE_SIZE: RenderSize = RenderSize { width: 960, height: 540 };
36const REMOTE_THUMBNAIL_SIZE: RenderSize = RenderSize { width: 320, height: 180 };
37const X_DAIS_TOKEN: HeaderName = HeaderName::from_static("x-dais-token");
38
39#[derive(Debug, Clone)]
41pub struct RemoteEndpoint {
42 pub host: String,
43 pub port: u16,
44 pub token: Option<String>,
45}
46
47impl Default for RemoteEndpoint {
48 fn default() -> Self {
49 Self { host: "127.0.0.1".to_string(), port: 4317, token: None }
50 }
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct RemoteOverrides {
56 pub enabled: bool,
57 pub host: Option<String>,
58 pub port: Option<u16>,
59}
60
61#[derive(Debug, Clone)]
63pub struct ServerSettings {
64 pub enabled: bool,
65 pub host: String,
66 pub port: u16,
67 pub token: String,
68 pub generated_token: bool,
69 pub allow_unauthenticated_loopback: bool,
70}
71
72impl ServerSettings {
73 pub fn from_config(config: &RemoteConfig, overrides: &RemoteOverrides) -> Self {
74 let host = overrides.host.clone().unwrap_or_else(|| config.host.clone());
75 let port = overrides.port.unwrap_or(config.port);
76 let enabled = overrides.enabled || config.enabled;
77 let generated_token = config.token.is_empty();
78 let token = if generated_token { generate_token() } else { config.token.clone() };
79 Self {
80 enabled,
81 host,
82 port,
83 token,
84 generated_token,
85 allow_unauthenticated_loopback: config.allow_unauthenticated_loopback,
86 }
87 }
88}
89
90pub struct RemoteServer {
92 addr: SocketAddr,
93 token: String,
94 allow_unauthenticated_loopback: bool,
95 urls: Vec<String>,
96 status: RemoteStatusHandle,
97 shutdown: Option<oneshot::Sender<()>>,
98 _handle: thread::JoinHandle<()>,
99}
100
101impl RemoteServer {
102 pub fn addr(&self) -> SocketAddr {
103 self.addr
104 }
105
106 pub fn token(&self) -> &str {
107 &self.token
108 }
109
110 pub fn requires_token_for_non_loopback(&self) -> bool {
111 !self.allow_unauthenticated_loopback || !self.addr.ip().is_loopback()
112 }
113
114 pub fn urls(&self) -> &[String] {
115 &self.urls
116 }
117
118 pub fn status(&self) -> RemoteStatus {
119 self.status.snapshot()
120 }
121
122 pub fn status_handle(&self) -> RemoteStatusHandle {
123 self.status.clone()
124 }
125}
126
127impl Drop for RemoteServer {
128 fn drop(&mut self) {
129 if let Some(shutdown) = self.shutdown.take() {
130 let _ = shutdown.send(());
131 }
132 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139pub struct RemoteState {
140 pub current_page: usize,
141 pub current_logical_slide: usize,
142 pub current_overlay_within_group: usize,
143 pub total_pages: usize,
144 pub total_logical_slides: usize,
145 pub audience_page: usize,
146 pub frozen: bool,
147 pub blacked_out: bool,
148 pub whiteboard_active: bool,
149 pub screen_share_mode: bool,
150 pub presentation_mode: bool,
151 pub laser_active: bool,
152 pub pointer_position: Option<(f32, f32)>,
153 pub ink_active: bool,
154 pub draw_tool: String,
155 pub eraser_radius: f32,
156 pub spotlight_active: bool,
157 pub spotlight_position: Option<(f32, f32)>,
158 pub zoom_active: bool,
159 pub overview_visible: bool,
160 pub notes_visible: bool,
161 pub notes_editing: bool,
162 pub current_notes: Option<String>,
163 pub ink_pen_color: [u8; 4],
164 pub ink_pen_width: f32,
165 pub ink_color_presets: Vec<[u8; 4]>,
166 pub ink_highlighter_color: [u8; 4],
167 pub ink_highlighter_width: f32,
168 pub ink_highlighter_color_presets: Vec<[u8; 4]>,
169 pub ink_strokes: Vec<RemoteInkStroke>,
170 pub text_box_mode: bool,
171 pub selected_text_box: Option<u64>,
172 pub text_box_editing: bool,
173 pub text_boxes: Vec<RemoteTextBox>,
174 pub timer: RemoteTimerState,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178pub struct RemoteInkStroke {
179 pub points: Vec<[f32; 2]>,
180 pub color: [u8; 4],
181 pub width: f32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct RemoteTextBox {
186 pub id: u64,
187 pub rect: [f32; 4],
188 pub content: String,
189 pub font_size: f32,
190 pub color: [u8; 4],
191 pub background: Option<[u8; 4]>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195pub struct RemoteTimerState {
196 pub running: bool,
197 pub elapsed_seconds: u64,
198 pub display_seconds: u64,
199 pub phase: String,
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
203pub struct RemoteStatus {
204 pub active_event_clients: usize,
205 pub last_command: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209pub struct CommandResponse {
210 pub ok: bool,
211 pub message: String,
212}
213
214#[derive(Debug, Clone, Deserialize)]
215struct GoToRequest {
216 slide: usize,
217}
218
219#[derive(Debug, Clone, Deserialize)]
220struct PointerRequest {
221 x: f32,
222 y: f32,
223}
224
225#[derive(Debug, Clone, Deserialize)]
226struct TimerRequest {
227 action: TimerRemoteAction,
228}
229
230#[derive(Debug, Clone, Deserialize)]
231struct NotesRequest {
232 notes: String,
233}
234
235#[derive(Debug, Clone, Deserialize)]
236struct InkStrokeRequest {
237 points: Vec<[f32; 2]>,
238 tool: Option<String>,
239 color: Option<[u8; 4]>,
240 width: Option<f32>,
241}
242
243#[derive(Debug, Clone, Deserialize)]
244struct InkEraseRequest {
245 points: Vec<[f32; 2]>,
246 radius: Option<f32>,
247}
248
249#[derive(Debug, Clone, Deserialize)]
250struct InkSetToolRequest {
251 tool: String,
252}
253
254#[derive(Debug, Clone, Deserialize)]
255struct TextBoxPlaceRequest {
256 x: f32,
257 y: f32,
258 w: f32,
259 h: f32,
260}
261
262#[derive(Debug, Clone, Deserialize)]
263struct TextBoxIdRequest {
264 id: u64,
265}
266
267#[derive(Debug, Clone, Deserialize)]
268struct TextBoxContentRequest {
269 id: u64,
270 content: String,
271}
272
273#[derive(Debug, Clone, Deserialize)]
274struct TextBoxMoveRequest {
275 id: u64,
276 x: f32,
277 y: f32,
278}
279
280#[derive(Debug, Clone, Deserialize)]
281struct TextBoxResizeRequest {
282 id: u64,
283 w: f32,
284 h: f32,
285}
286
287#[derive(Debug, Clone, Deserialize)]
288struct TextBoxSvgQuery {
289 w: Option<u32>,
290 h: Option<u32>,
291 slide_w: Option<u32>,
292 slide_h: Option<u32>,
293}
294
295#[derive(Debug, Clone, Copy, Deserialize)]
296#[serde(rename_all = "lowercase")]
297enum TimerRemoteAction {
298 Start,
299 Pause,
300 Toggle,
301 Reset,
302}
303
304#[derive(Clone)]
305struct HandlerContext {
306 settings: Arc<ServerSettings>,
307 sender: CommandSender,
308 shared_state: Arc<RwLock<PresentationState>>,
309 doc: Arc<dyn DocumentSource>,
310 status: RemoteStatusHandle,
311 png_cache: Arc<Mutex<HashMap<PngCacheKey, Vec<u8>>>>,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
315struct PngCacheKey {
316 page_index: usize,
317 size: RenderSize,
318}
319
320#[derive(Debug, Clone, Default)]
321pub struct RemoteStatusHandle {
322 inner: Arc<RwLock<RemoteStatus>>,
323}
324
325impl RemoteStatusHandle {
326 pub fn snapshot(&self) -> RemoteStatus {
327 self.inner.read().map_or_else(|_| RemoteStatus::default(), |status| status.clone())
328 }
329
330 fn set_last_command(&self, command: impl Into<String>) {
331 if let Ok(mut status) = self.inner.write() {
332 status.last_command = Some(command.into());
333 }
334 }
335
336 fn add_event_client(&self) {
337 if let Ok(mut status) = self.inner.write() {
338 status.active_event_clients += 1;
339 }
340 }
341
342 fn remove_event_client(&self) {
343 if let Ok(mut status) = self.inner.write() {
344 status.active_event_clients = status.active_event_clients.saturating_sub(1);
345 }
346 }
347}
348
349pub fn start_server(
350 mut settings: ServerSettings,
351 sender: CommandSender,
352 shared_state: Arc<RwLock<PresentationState>>,
353 doc: Arc<dyn DocumentSource>,
354) -> Result<RemoteServer> {
355 if settings.generated_token {
356 if !generated_pairing_code_is_valid(&settings.token) {
357 return Err(anyhow!("generated remote API pairing code is invalid"));
358 }
359 } else if !custom_token_is_valid(&settings.token) {
360 return Err(anyhow!(
361 "configured remote API token may only contain ASCII letters and digits"
362 ));
363 }
364
365 let std_listener = std::net::TcpListener::bind((settings.host.as_str(), settings.port))
366 .with_context(|| {
367 format!("failed to bind remote API to {}:{}", settings.host, settings.port)
368 })?;
369 std_listener.set_nonblocking(true).context("failed to set remote API listener nonblocking")?;
370 let addr = std_listener.local_addr().context("failed to read remote API address")?;
371 settings.port = addr.port();
372 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
373 let token = settings.token.clone();
374 let allow_unauthenticated_loopback = server_settings_allow_loopback(&settings);
375 let urls = remote_urls(addr);
376 let status = RemoteStatusHandle::default();
377 let context = HandlerContext {
378 settings: Arc::new(settings),
379 sender,
380 shared_state,
381 doc,
382 status: status.clone(),
383 png_cache: Arc::new(Mutex::new(HashMap::new())),
384 };
385
386 let handle = thread::spawn(move || match tokio::runtime::Runtime::new() {
387 Ok(runtime) => {
388 runtime.block_on(async move {
389 let listener = match tokio::net::TcpListener::from_std(std_listener) {
390 Ok(listener) => listener,
391 Err(error) => {
392 tracing::error!("failed to create remote API listener: {error}");
393 return;
394 }
395 };
396 let app = remote_router(context);
397 if let Err(error) =
398 axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
399 .with_graceful_shutdown(async {
400 let _ = shutdown_rx.await;
401 })
402 .await
403 {
404 tracing::warn!("remote API server stopped with error: {error}");
405 }
406 });
407 }
408 Err(error) => tracing::error!("failed to start remote API runtime: {error}"),
409 });
410
411 Ok(RemoteServer {
412 addr,
413 token,
414 allow_unauthenticated_loopback,
415 urls,
416 status,
417 shutdown: Some(shutdown_tx),
418 _handle: handle,
419 })
420}
421
422fn remote_router(context: HandlerContext) -> Router {
423 Router::new()
424 .route("/", get(web_remote))
425 .route("/remote", get(web_remote))
426 .route("/api/v1/state", get(get_state))
427 .route("/api/v1/events", get(events))
428 .route("/api/v1/remote-status", get(remote_status))
429 .route("/api/v1/actions/{action}", post(action))
430 .route("/api/v1/commands/goto", post(goto))
431 .route("/api/v1/commands/pointer", post(pointer))
432 .route("/api/v1/commands/timer", post(timer))
433 .route("/api/v1/commands/notes", post(set_notes))
434 .route("/api/v1/commands/ink/stroke", post(ink_stroke))
435 .route("/api/v1/commands/ink/erase", post(ink_erase))
436 .route("/api/v1/commands/ink/set_tool", post(ink_set_tool))
437 .route("/api/v1/commands/ink/clear", post(ink_clear))
438 .route("/api/v1/commands/text-boxes/place", post(text_box_place))
439 .route("/api/v1/commands/text-boxes/select", post(text_box_select))
440 .route("/api/v1/commands/text-boxes/content", post(text_box_content))
441 .route("/api/v1/commands/text-boxes/move", post(text_box_move))
442 .route("/api/v1/commands/text-boxes/resize", post(text_box_resize))
443 .route("/api/v1/commands/text-boxes/delete", post(text_box_delete))
444 .route("/api/v1/text-boxes/{id}/svg", get(text_box_svg))
445 .route("/api/v1/slides/current.png", get(current_slide_png))
446 .route("/api/v1/slides/next.png", get(next_slide_png))
447 .route("/api/v1/slides/{slide}/thumbnail.png", get(thumbnail_png))
448 .layer(SetSensitiveRequestHeadersLayer::new([AUTHORIZATION, X_DAIS_TOKEN]))
449 .route_layer(middleware::from_fn_with_state(context.clone(), request_guard))
450 .with_state(context)
451}
452
453async fn request_guard(
454 State(context): State<HandlerContext>,
455 ConnectInfo(peer): ConnectInfo<SocketAddr>,
456 headers: HeaderMap,
457 request: Request,
458 next: Next,
459) -> Response {
460 let query = request.uri().query();
461 if !host_header_allowed(&headers, &context.settings) || !origin_allowed(&headers) {
462 return StatusCode::FORBIDDEN.into_response();
463 }
464
465 if !is_authorized(&headers, query, peer, &context.settings) {
466 return StatusCode::UNAUTHORIZED.into_response();
467 }
468
469 next.run(request).await
470}
471
472async fn web_remote() -> Html<&'static str> {
473 Html(remote_html())
474}
475
476async fn get_state(State(context): State<HandlerContext>) -> Response {
477 match remote_state_snapshot(&context.shared_state) {
478 Ok((state, pages)) => {
479 preload_pages(&context, pages);
480 Json(state).into_response()
481 }
482 Err(error) => server_error(&error),
483 }
484}
485
486async fn remote_status(State(context): State<HandlerContext>) -> Json<RemoteStatus> {
487 Json(context.status.snapshot())
488}
489
490async fn current_slide_png(State(context): State<HandlerContext>) -> Response {
491 match current_page(&context.shared_state)
492 .and_then(|page| cached_png(&context, page, REMOTE_SLIDE_SIZE))
493 {
494 Ok(png) => png_response(png),
495 Err(error) => server_error(&error),
496 }
497}
498
499async fn next_slide_png(State(context): State<HandlerContext>) -> Response {
500 match next_page(&context.shared_state)
501 .and_then(|page| cached_png(&context, page, REMOTE_SLIDE_SIZE))
502 {
503 Ok(png) => png_response(png),
504 Err(error) => server_error(&error),
505 }
506}
507
508async fn thumbnail_png(
509 State(context): State<HandlerContext>,
510 Path(slide): Path<usize>,
511) -> Response {
512 if slide == 0 {
513 return bad_request(&anyhow!("slide must be 1 or greater"));
514 }
515
516 match logical_slide_page(&context.shared_state, slide - 1)
517 .and_then(|page| cached_png(&context, page, REMOTE_THUMBNAIL_SIZE))
518 {
519 Ok(png) => png_response(png),
520 Err(error) => bad_request(&error),
521 }
522}
523
524async fn action(State(context): State<HandlerContext>, Path(action): Path<String>) -> Response {
525 match send_remote_action(&context.sender, &action) {
526 Ok(()) => {
527 context.status.set_last_command(action);
528 preload_from_shared_state(&context);
529 Json(ok_response("action dispatched")).into_response()
530 }
531 Err(error) => bad_request(&error),
532 }
533}
534
535async fn goto(State(context): State<HandlerContext>, Json(body): Json<GoToRequest>) -> Response {
536 if body.slide == 0 {
537 return bad_request(&anyhow!("slide must be 1 or greater"));
538 }
539
540 match context.sender.send(Command::GoToSlide(body.slide - 1)) {
541 Ok(()) => {
542 context.status.set_last_command(format!("goto {}", body.slide));
543 preload_from_shared_state(&context);
544 Json(ok_response("goto dispatched")).into_response()
545 }
546 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
547 }
548}
549
550async fn pointer(
551 State(context): State<HandlerContext>,
552 Json(body): Json<PointerRequest>,
553) -> Response {
554 match context.sender.send(Command::SetPointerPosition(body.x, body.y)) {
555 Ok(()) => {
556 context.status.set_last_command("pointer");
557 preload_from_shared_state(&context);
558 Json(ok_response("pointer dispatched")).into_response()
559 }
560 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
561 }
562}
563
564async fn timer(State(context): State<HandlerContext>, Json(body): Json<TimerRequest>) -> Response {
565 let command = match body.action {
566 TimerRemoteAction::Start => Command::StartTimer,
567 TimerRemoteAction::Pause => Command::PauseTimer,
568 TimerRemoteAction::Toggle => Command::ToggleTimer,
569 TimerRemoteAction::Reset => Command::ResetTimer,
570 };
571
572 match context.sender.send(command) {
573 Ok(()) => {
574 context.status.set_last_command(format!("timer {:?}", body.action));
575 preload_from_shared_state(&context);
576 Json(ok_response("timer command dispatched")).into_response()
577 }
578 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
579 }
580}
581
582async fn set_notes(
583 State(context): State<HandlerContext>,
584 Json(body): Json<NotesRequest>,
585) -> Response {
586 let send_result = context
587 .sender
588 .send(Command::SetCurrentSlideNotes(body.notes))
589 .and_then(|()| context.sender.send(Command::SaveSidecar));
590 match send_result {
591 Ok(()) => {
592 context.status.set_last_command("set_notes");
593 Json(ok_response("notes updated")).into_response()
594 }
595 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
596 }
597}
598
599async fn ink_stroke(
600 State(context): State<HandlerContext>,
601 Json(body): Json<InkStrokeRequest>,
602) -> Response {
603 if body.points.len() < 2 {
604 return bad_request(&anyhow!("stroke requires at least 2 points"));
605 }
606
607 let ink_was_active = context.shared_state.read().is_ok_and(|s| s.ink_active);
608 let requested_tool = match body.tool.as_deref() {
609 Some("pen") => Some(dais_core::state::DrawTool::Pen),
610 Some("highlighter") => Some(dais_core::state::DrawTool::Highlighter),
611 Some("eraser") => {
612 return bad_request(&anyhow!("eraser strokes use /api/v1/commands/ink/erase"));
613 }
614 Some(tool) => return bad_request(&anyhow!("unknown tool: {tool}")),
615 None => None,
616 };
617
618 let mut cmds = Vec::with_capacity(body.points.len() + 6);
619 if !ink_was_active {
620 cmds.push(Command::ToggleInk);
621 }
622 if let Some(tool) = requested_tool {
623 cmds.push(Command::SetDrawTool(tool));
624 }
625 if let Some(color) = body.color {
626 cmds.push(Command::SetInkColor(color));
627 }
628 if let Some(width) = body.width {
629 cmds.push(Command::SetInkWidth(width));
630 }
631 for &[x, y] in &body.points {
632 cmds.push(Command::AddInkPoint(x, y));
633 }
634 cmds.push(Command::FinishInkStroke);
635 if !ink_was_active {
636 cmds.push(Command::ToggleInk);
637 }
638 cmds.push(Command::SaveSidecar);
639
640 for cmd in cmds {
641 if context.sender.send(cmd).is_err() {
642 return server_error(&anyhow!("presentation engine is not accepting commands"));
643 }
644 }
645
646 context.status.set_last_command("ink_stroke");
647 Json(ok_response("stroke added")).into_response()
648}
649
650async fn ink_set_tool(
651 State(context): State<HandlerContext>,
652 Json(body): Json<InkSetToolRequest>,
653) -> Response {
654 let tool = match body.tool.as_str() {
655 "pen" => dais_core::state::DrawTool::Pen,
656 "highlighter" => dais_core::state::DrawTool::Highlighter,
657 "eraser" => dais_core::state::DrawTool::Eraser,
658 _ => return bad_request(&anyhow::anyhow!("unknown tool: {}", body.tool)),
659 };
660 match context.sender.send(Command::SetDrawTool(tool)) {
661 Ok(()) => {
662 wait_for_draw_tool(&context.shared_state, tool).await;
663 context.status.set_last_command("ink_set_tool");
664 Json(ok_response("draw tool set")).into_response()
665 }
666 Err(_) => server_error(&anyhow::anyhow!("presentation engine is not accepting commands")),
667 }
668}
669
670async fn wait_for_draw_tool(
671 shared_state: &Arc<RwLock<PresentationState>>,
672 tool: dais_core::state::DrawTool,
673) {
674 let deadline = Instant::now() + COMMAND_APPLY_TIMEOUT;
675 while Instant::now() < deadline {
676 if shared_state.read().is_ok_and(|state| state.draw_tool == tool) {
677 return;
678 }
679 tokio::time::sleep(COMMAND_APPLY_POLL).await;
680 }
681}
682
683async fn ink_erase(
684 State(context): State<HandlerContext>,
685 Json(body): Json<InkEraseRequest>,
686) -> Response {
687 if body.points.is_empty() {
688 return bad_request(&anyhow::anyhow!("erase requires at least one point"));
689 }
690
691 let radius = body.radius.unwrap_or(0.03).clamp(0.001, 0.5);
692
693 for &[x, y] in &body.points {
694 if context.sender.send(Command::EraseInkNear { x, y, radius }).is_err() {
695 return server_error(&anyhow::anyhow!("presentation engine is not accepting commands"));
696 }
697 }
698
699 if context.sender.send(Command::SaveSidecar).is_err() {
700 return server_error(&anyhow::anyhow!("presentation engine is not accepting commands"));
701 }
702
703 context.status.set_last_command("ink_erase");
704 Json(ok_response("erase applied")).into_response()
705}
706
707async fn ink_clear(State(context): State<HandlerContext>) -> Response {
708 match context
709 .sender
710 .send(Command::ClearInk)
711 .and_then(|()| context.sender.send(Command::SaveSidecar))
712 {
713 Ok(()) => {
714 context.status.set_last_command("ink_clear");
715 Json(ok_response("ink cleared")).into_response()
716 }
717 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
718 }
719}
720
721async fn text_box_place(
722 State(context): State<HandlerContext>,
723 Json(body): Json<TextBoxPlaceRequest>,
724) -> Response {
725 if body.w <= 0.0 || body.h <= 0.0 {
726 return bad_request(&anyhow!("text box size must be positive"));
727 }
728
729 match context
730 .sender
731 .send(Command::PlaceTextBox { x: body.x, y: body.y, w: body.w, h: body.h })
732 .and_then(|()| context.sender.send(Command::SaveSidecar))
733 {
734 Ok(()) => {
735 context.status.set_last_command("text_box_place");
736 Json(ok_response("text box placed")).into_response()
737 }
738 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
739 }
740}
741
742async fn text_box_select(
743 State(context): State<HandlerContext>,
744 Json(body): Json<TextBoxIdRequest>,
745) -> Response {
746 match context.sender.send(Command::SelectTextBox(body.id)) {
747 Ok(()) => {
748 context.status.set_last_command("text_box_select");
749 Json(ok_response("text box selected")).into_response()
750 }
751 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
752 }
753}
754
755async fn text_box_content(
756 State(context): State<HandlerContext>,
757 Json(body): Json<TextBoxContentRequest>,
758) -> Response {
759 match context
760 .sender
761 .send(Command::EditTextBoxContent { id: body.id, content: body.content })
762 .and_then(|()| context.sender.send(Command::SaveSidecar))
763 {
764 Ok(()) => {
765 context.status.set_last_command("text_box_content");
766 Json(ok_response("text box content updated")).into_response()
767 }
768 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
769 }
770}
771
772async fn text_box_move(
773 State(context): State<HandlerContext>,
774 Json(body): Json<TextBoxMoveRequest>,
775) -> Response {
776 match context
777 .sender
778 .send(Command::MoveTextBox { id: body.id, x: body.x, y: body.y })
779 .and_then(|()| context.sender.send(Command::SaveSidecar))
780 {
781 Ok(()) => {
782 context.status.set_last_command("text_box_move");
783 Json(ok_response("text box moved")).into_response()
784 }
785 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
786 }
787}
788
789async fn text_box_resize(
790 State(context): State<HandlerContext>,
791 Json(body): Json<TextBoxResizeRequest>,
792) -> Response {
793 if body.w <= 0.0 || body.h <= 0.0 {
794 return bad_request(&anyhow!("text box size must be positive"));
795 }
796
797 match context
798 .sender
799 .send(Command::ResizeTextBox { id: body.id, w: body.w, h: body.h })
800 .and_then(|()| context.sender.send(Command::SaveSidecar))
801 {
802 Ok(()) => {
803 context.status.set_last_command("text_box_resize");
804 Json(ok_response("text box resized")).into_response()
805 }
806 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
807 }
808}
809
810async fn text_box_delete(
811 State(context): State<HandlerContext>,
812 Json(body): Json<TextBoxIdRequest>,
813) -> Response {
814 match context
815 .sender
816 .send(Command::DeleteTextBox { id: body.id })
817 .and_then(|()| context.sender.send(Command::SaveSidecar))
818 {
819 Ok(()) => {
820 context.status.set_last_command("text_box_delete");
821 Json(ok_response("text box deleted")).into_response()
822 }
823 Err(_) => server_error(&anyhow!("presentation engine is not accepting commands")),
824 }
825}
826
827async fn text_box_svg(
828 State(context): State<HandlerContext>,
829 Path(id): Path<u64>,
830 Query(query): Query<TextBoxSvgQuery>,
831) -> Response {
832 let width = query.w.unwrap_or(320).clamp(1, 3840);
833 let height = query.h.unwrap_or(120).clamp(1, 2160);
834 let slide_width = query.slide_w.unwrap_or(REMOTE_SLIDE_SIZE.width).clamp(1, 3840);
835 let slide_height = query.slide_h.unwrap_or(REMOTE_SLIDE_SIZE.height).clamp(1, 2160);
836 let text_box = match current_text_box(&context.shared_state, id) {
837 Ok(Some(text_box)) => text_box,
838 Ok(None) => return (StatusCode::NOT_FOUND, "text box not found").into_response(),
839 Err(error) => return server_error(&error),
840 };
841 let font_size = remote_text_box_font_size(&text_box, slide_width, slide_height);
842
843 match render_text_box_svg(
844 &text_box.content,
845 &text_box.typst_prelude,
846 width,
847 height,
848 font_size,
849 text_box.color,
850 text_box.background,
851 ) {
852 Some(svg) => ([(axum::http::header::CONTENT_TYPE, "image/svg+xml")], svg).into_response(),
853 None => server_error(&anyhow!("failed to render text box SVG")),
854 }
855}
856
857async fn events(
858 State(context): State<HandlerContext>,
859) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
860 context.status.add_event_client();
861 let status = context.status.clone();
862 let shared_state = Arc::clone(&context.shared_state);
863 let stream = async_stream::stream! {
864 let _guard = EventClientGuard { status };
865 let mut interval = tokio::time::interval(SSE_INTERVAL);
866 loop {
867 interval.tick().await;
868 match remote_state_snapshot(&shared_state) {
869 Ok((state, _pages)) => {
870 let json = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string());
871 yield Ok(Event::default().data(json));
872 }
873 Err(_) => break,
874 }
875 }
876 };
877
878 Sse::new(stream).keep_alive(KeepAlive::default())
879}
880
881struct EventClientGuard {
882 status: RemoteStatusHandle,
883}
884
885impl Drop for EventClientGuard {
886 fn drop(&mut self) {
887 self.status.remove_event_client();
888 }
889}
890
891fn png_response(png: Vec<u8>) -> Response {
892 ([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response()
893}
894
895fn bad_request(error: &anyhow::Error) -> Response {
896 (StatusCode::BAD_REQUEST, error.to_string()).into_response()
897}
898
899fn server_error(error: &anyhow::Error) -> Response {
900 (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
901}
902
903fn server_settings_allow_loopback(settings: &ServerSettings) -> bool {
904 settings.allow_unauthenticated_loopback && host_is_loopback(&settings.host)
905}
906
907pub fn remote_state(state: &PresentationState) -> RemoteState {
908 RemoteState {
909 current_page: state.current_page,
910 current_logical_slide: state.current_logical_slide,
911 current_overlay_within_group: state.current_overlay_within_group,
912 total_pages: state.total_pages,
913 total_logical_slides: state.total_logical_slides,
914 audience_page: state.audience_page(),
915 frozen: state.frozen,
916 blacked_out: state.blacked_out,
917 whiteboard_active: state.whiteboard_active,
918 screen_share_mode: state.screen_share_mode,
919 presentation_mode: state.presentation_mode,
920 laser_active: state.laser_active,
921 pointer_position: state.pointer_position,
922 ink_active: state.ink_active,
923 draw_tool: draw_tool_name(state.draw_tool).to_string(),
924 eraser_radius: state.eraser_radius,
925 spotlight_active: state.spotlight_active,
926 spotlight_position: state.spotlight_position,
927 zoom_active: state.zoom_active,
928 overview_visible: state.overview_visible,
929 notes_visible: state.notes_visible,
930 notes_editing: state.notes_editing,
931 current_notes: state.current_notes.clone(),
932 ink_pen_color: state.active_pen.color,
933 ink_pen_width: state.active_pen.width,
934 ink_color_presets: state.ink_color_presets.clone(),
935 ink_highlighter_color: state.active_highlighter.color,
936 ink_highlighter_width: state.active_highlighter.width,
937 ink_highlighter_color_presets: state.highlighter_color_presets.clone(),
938 ink_strokes: remote_ink_strokes(state),
939 text_box_mode: state.text_box_mode,
940 selected_text_box: state.selected_text_box,
941 text_box_editing: state.text_box_editing,
942 text_boxes: remote_text_boxes(state),
943 timer: RemoteTimerState {
944 running: state.timer.running,
945 elapsed_seconds: state.timer.elapsed.as_secs(),
946 display_seconds: state.timer.display_time().as_secs(),
947 phase: timer_phase_name(state.timer.phase()).to_string(),
948 },
949 }
950}
951
952fn remote_ink_strokes(state: &PresentationState) -> Vec<RemoteInkStroke> {
953 let strokes = if state.whiteboard_active {
954 state.whiteboard_strokes.as_slice()
955 } else {
956 state.current_page_ink()
957 };
958 strokes
959 .iter()
960 .filter(|stroke| stroke.points.len() >= 2)
961 .map(|stroke| RemoteInkStroke {
962 points: stroke.points.iter().map(|&(x, y)| [x, y]).collect(),
963 color: stroke.color,
964 width: stroke.width,
965 })
966 .collect()
967}
968
969fn remote_text_boxes(state: &PresentationState) -> Vec<RemoteTextBox> {
970 if state.whiteboard_active {
971 return Vec::new();
972 }
973
974 state
975 .current_page_text_boxes()
976 .iter()
977 .map(|text_box| {
978 let (x, y, w, h) = text_box.rect;
979 RemoteTextBox {
980 id: text_box.id,
981 rect: [x, y, w, h],
982 content: text_box.content.clone(),
983 font_size: text_box.font_size,
984 color: text_box.color,
985 background: text_box.background,
986 }
987 })
988 .collect()
989}
990
991pub fn command_for_action_name(name: &str) -> Option<Command> {
992 action_to_command(Action::from_config_name(name)?)
993}
994
995pub fn send_remote_action(sender: &CommandSender, name: &str) -> Result<()> {
996 let command =
997 command_for_action_name(name).ok_or_else(|| anyhow!("unknown remote action '{name}'"))?;
998 sender.send(command).map_err(|_| anyhow!("presentation engine is not accepting commands"))
999}
1000
1001pub fn client_get_state(endpoint: &RemoteEndpoint) -> Result<RemoteState> {
1002 let client = reqwest::blocking::Client::builder()
1003 .timeout(Duration::from_secs(3))
1004 .build()
1005 .context("failed to create remote API client")?;
1006 let request = client.get(endpoint_url(endpoint, "/api/v1/state"));
1007 let request =
1008 if let Some(token) = &endpoint.token { request.bearer_auth(token) } else { request };
1009 let response = request.send().with_context(|| {
1010 format!("failed to connect to remote API at {}:{}", endpoint.host, endpoint.port)
1011 })?;
1012 let status = response.status();
1013 if !status.is_success() {
1014 let text = response.text().unwrap_or_default();
1015 return Err(anyhow!("remote API returned {}: {text}", status.as_u16()));
1016 }
1017 response.json().context("failed to parse remote state")
1018}
1019
1020pub fn client_action(endpoint: &RemoteEndpoint, action: &str) -> Result<CommandResponse> {
1021 client_json_request(endpoint, "POST", &format!("/api/v1/actions/{action}"), &())
1022}
1023
1024pub fn client_goto(endpoint: &RemoteEndpoint, slide: usize) -> Result<CommandResponse> {
1025 client_json_request(
1026 endpoint,
1027 "POST",
1028 "/api/v1/commands/goto",
1029 &serde_json::json!({ "slide": slide }),
1030 )
1031}
1032
1033pub fn client_pointer(endpoint: &RemoteEndpoint, x: f32, y: f32) -> Result<CommandResponse> {
1034 client_json_request(
1035 endpoint,
1036 "POST",
1037 "/api/v1/commands/pointer",
1038 &serde_json::json!({ "x": x, "y": y }),
1039 )
1040}
1041
1042pub fn client_timer(endpoint: &RemoteEndpoint, action: &str) -> Result<CommandResponse> {
1043 client_json_request(
1044 endpoint,
1045 "POST",
1046 "/api/v1/commands/timer",
1047 &serde_json::json!({ "action": action }),
1048 )
1049}
1050
1051pub fn client_notes(endpoint: &RemoteEndpoint, notes: &str) -> Result<CommandResponse> {
1052 client_json_request(
1053 endpoint,
1054 "POST",
1055 "/api/v1/commands/notes",
1056 &serde_json::json!({ "notes": notes }),
1057 )
1058}
1059
1060pub fn client_ink_stroke(
1061 endpoint: &RemoteEndpoint,
1062 points: &[[f32; 2]],
1063 color: Option<[u8; 4]>,
1064 width: Option<f32>,
1065) -> Result<CommandResponse> {
1066 client_json_request(
1067 endpoint,
1068 "POST",
1069 "/api/v1/commands/ink/stroke",
1070 &serde_json::json!({ "points": points, "color": color, "width": width }),
1071 )
1072}
1073
1074pub fn client_ink_clear(endpoint: &RemoteEndpoint) -> Result<CommandResponse> {
1075 client_json_request(endpoint, "POST", "/api/v1/commands/ink/clear", &serde_json::json!({}))
1076}
1077
1078fn client_json_request<T: Serialize>(
1079 endpoint: &RemoteEndpoint,
1080 method: &str,
1081 path: &str,
1082 body: &T,
1083) -> Result<CommandResponse> {
1084 let client = reqwest::blocking::Client::builder()
1085 .timeout(Duration::from_secs(3))
1086 .build()
1087 .context("failed to create remote API client")?;
1088 let request = client.request(method.parse()?, endpoint_url(endpoint, path)).json(body);
1089 let request =
1090 if let Some(token) = &endpoint.token { request.bearer_auth(token) } else { request };
1091 let response = request.send().with_context(|| {
1092 format!("failed to connect to remote API at {}:{}", endpoint.host, endpoint.port)
1093 })?;
1094 let status = response.status();
1095 if !status.is_success() {
1096 let text = response.text().unwrap_or_default();
1097 return Err(anyhow!("remote API returned {}: {text}", status.as_u16()));
1098 }
1099 response.json().context("failed to parse remote command response")
1100}
1101
1102fn endpoint_url(endpoint: &RemoteEndpoint, path: &str) -> String {
1103 format!("http://{}:{}{path}", endpoint.host, endpoint.port)
1104}
1105
1106fn remote_state_snapshot(
1107 shared_state: &Arc<RwLock<PresentationState>>,
1108) -> Result<(RemoteState, Vec<usize>)> {
1109 let state = shared_state.read().map_err(|_| anyhow!("state lock poisoned"))?;
1110 let dto = remote_state(&state);
1111 let pages = preloaded_pages(&state);
1112 Ok((dto, pages))
1113}
1114
1115fn ok_response(message: &str) -> CommandResponse {
1116 CommandResponse { ok: true, message: message.to_string() }
1117}
1118
1119fn is_authorized(
1120 headers: &HeaderMap,
1121 query: Option<&str>,
1122 peer: SocketAddr,
1123 settings: &ServerSettings,
1124) -> bool {
1125 if settings.allow_unauthenticated_loopback && peer.ip().is_loopback() {
1126 return true;
1127 }
1128
1129 request_token(headers, query).is_some_and(|token| token == settings.token)
1130}
1131
1132fn request_token<'a>(headers: &'a HeaderMap, query: Option<&'a str>) -> Option<&'a str> {
1133 if let Some(token) = query_token(query) {
1134 return Some(token);
1135 }
1136 if let Some(auth) = header_str(headers, AUTHORIZATION) {
1137 return auth.strip_prefix("Bearer ").or(Some(auth));
1138 }
1139 header_str(headers, &X_DAIS_TOKEN)
1140}
1141
1142fn query_token(query: Option<&str>) -> Option<&str> {
1143 query?
1144 .split('&')
1145 .filter_map(|part| part.split_once('='))
1146 .find_map(|(key, value)| (key == "token").then_some(value))
1147}
1148
1149fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option<&str> {
1150 headers.get(name)?.to_str().ok()
1151}
1152
1153fn host_header_allowed(headers: &HeaderMap, settings: &ServerSettings) -> bool {
1154 let Some(host) = header_str(headers, HOST) else {
1155 return false;
1156 };
1157 let Some((name, port)) = split_host_port(host) else {
1158 return false;
1159 };
1160 port == effective_port(settings) && host_name_allowed(name, settings)
1161}
1162
1163fn origin_allowed(headers: &HeaderMap) -> bool {
1164 let Some(origin) = header_str(headers, ORIGIN) else {
1165 return true;
1166 };
1167 let Some(host) = header_str(headers, HOST) else {
1168 return false;
1169 };
1170 origin == format!("http://{host}") || origin == format!("https://{host}")
1171}
1172
1173fn split_host_port(host: &str) -> Option<(&str, u16)> {
1174 let host = host.trim();
1175 let (name, port) = host.rsplit_once(':')?;
1176 Some((name.trim_matches(['[', ']']), port.parse().ok()?))
1177}
1178
1179fn effective_port(settings: &ServerSettings) -> u16 {
1180 settings.port
1181}
1182
1183fn host_name_allowed(name: &str, settings: &ServerSettings) -> bool {
1184 if name.eq_ignore_ascii_case("localhost") {
1185 return true;
1186 }
1187
1188 let Ok(host_ip) = name.parse::<IpAddr>() else {
1189 return false;
1190 };
1191
1192 if host_ip.is_loopback() {
1193 return true;
1194 }
1195
1196 let Ok(bind_ip) = settings.host.parse::<IpAddr>() else {
1197 return false;
1198 };
1199
1200 host_ip == bind_ip || bind_ip.is_unspecified()
1201}
1202
1203fn host_is_loopback(host: &str) -> bool {
1204 host.eq_ignore_ascii_case("localhost")
1205 || host.parse::<IpAddr>().is_ok_and(|addr| addr.is_loopback())
1206}
1207
1208fn generate_token() -> String {
1209 let code = fastrand::u32(0..100_000_000);
1210 format!("{:04}-{:04}", code / 10_000, code % 10_000)
1211}
1212
1213fn custom_token_is_valid(token: &str) -> bool {
1214 !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_alphanumeric())
1215}
1216
1217fn generated_pairing_code_is_valid(token: &str) -> bool {
1218 token.len() == 9
1219 && token.as_bytes().get(4) == Some(&b'-')
1220 && token
1221 .bytes()
1222 .enumerate()
1223 .all(|(index, byte)| if index == 4 { byte == b'-' } else { byte.is_ascii_digit() })
1224}
1225
1226fn current_page(shared_state: &Arc<RwLock<PresentationState>>) -> Result<usize> {
1227 Ok(shared_state.read().map_err(|_| anyhow!("state lock poisoned"))?.current_page)
1228}
1229
1230fn next_page(shared_state: &Arc<RwLock<PresentationState>>) -> Result<usize> {
1231 let state = shared_state.read().map_err(|_| anyhow!("state lock poisoned"))?;
1232 let group = state.current_logical_slide.saturating_add(1);
1233 if let Some(next) = state.slide_groups.get(group).and_then(|group| group.pages.first()) {
1234 Ok(*next)
1235 } else {
1236 Ok(state.current_page)
1237 }
1238}
1239
1240fn logical_slide_page(
1241 shared_state: &Arc<RwLock<PresentationState>>,
1242 logical_slide: usize,
1243) -> Result<usize> {
1244 let state = shared_state.read().map_err(|_| anyhow!("state lock poisoned"))?;
1245 state
1246 .slide_groups
1247 .get(logical_slide)
1248 .and_then(|group| group.pages.first())
1249 .copied()
1250 .ok_or_else(|| anyhow!("logical slide out of range"))
1251}
1252
1253fn cached_png(context: &HandlerContext, page_index: usize, size: RenderSize) -> Result<Vec<u8>> {
1254 let key = PngCacheKey { page_index, size };
1255 if let Ok(cache) = context.png_cache.lock()
1256 && let Some(png) = cache.get(&key)
1257 {
1258 return Ok(png.clone());
1259 }
1260
1261 let png = render_png(&context.doc, page_index, size)?;
1262 if let Ok(mut cache) = context.png_cache.lock() {
1263 cache.insert(key, png.clone());
1264 if cache.len() > 12
1265 && let Some(oldest) = cache.keys().copied().next()
1266 {
1267 cache.remove(&oldest);
1268 }
1269 }
1270 Ok(png)
1271}
1272
1273fn preload_from_shared_state(context: &HandlerContext) {
1274 if let Ok((_state, pages)) = remote_state_snapshot(&context.shared_state) {
1275 preload_pages(context, pages);
1276 }
1277}
1278
1279fn preloaded_pages(state: &PresentationState) -> Vec<usize> {
1280 [
1281 Some(state.current_page),
1282 state
1283 .slide_groups
1284 .get(state.current_logical_slide.saturating_add(1))
1285 .and_then(|group| group.pages.first())
1286 .copied(),
1287 ]
1288 .into_iter()
1289 .flatten()
1290 .collect()
1291}
1292
1293fn preload_pages(context: &HandlerContext, pages: Vec<usize>) {
1294 for page in pages {
1295 let _ = cached_png(context, page, REMOTE_SLIDE_SIZE);
1296 }
1297}
1298
1299fn render_png(
1300 doc: &Arc<dyn DocumentSource>,
1301 page_index: usize,
1302 size: RenderSize,
1303) -> Result<Vec<u8>> {
1304 let page = doc
1305 .render_page(page_index, size)
1306 .with_context(|| format!("failed to render page {}", page_index + 1))?;
1307 let mut png = Vec::new();
1308 PngEncoder::new(&mut png)
1309 .write_image(&page.data, page.width, page.height, ColorType::Rgba8.into())
1310 .context("failed to encode slide PNG")?;
1311 Ok(png)
1312}
1313
1314fn current_text_box(
1315 shared_state: &Arc<RwLock<PresentationState>>,
1316 id: u64,
1317) -> Result<Option<dais_core::state::TextBox>> {
1318 let state = shared_state.read().map_err(|_| anyhow!("state lock poisoned"))?;
1319 Ok(state.current_page_text_boxes().iter().find(|text_box| text_box.id == id).cloned())
1320}
1321
1322#[allow(clippy::cast_precision_loss)]
1323fn remote_text_box_font_size(
1324 text_box: &dais_core::state::TextBox,
1325 slide_width: u32,
1326 slide_height: u32,
1327) -> f32 {
1328 let width_scale = slide_width as f32 / FALLBACK_RENDER_SIZE.width as f32;
1329 let height_scale = slide_height as f32 / FALLBACK_RENDER_SIZE.height as f32;
1330 let scale = width_scale.min(height_scale).max(0.05);
1331 (text_box.font_size.clamp(8.0, 72.0) * scale).max(1.0)
1332}
1333
1334fn remote_urls(addr: SocketAddr) -> Vec<String> {
1335 let mut urls = Vec::new();
1336 if addr.ip().is_unspecified() {
1337 urls.push(format!("http://127.0.0.1:{}/remote", addr.port()));
1338 if let Some(ip) = likely_lan_ip() {
1339 urls.push(format!("http://{}:{}/remote", ip, addr.port()));
1340 }
1341 } else {
1342 urls.push(format!("http://{addr}/remote"));
1343 }
1344
1345 if !addr.ip().is_loopback()
1346 && !addr.ip().is_unspecified()
1347 && let Some(ip) = likely_lan_ip()
1348 {
1349 urls.push(format!("http://{}:{}/remote", ip, addr.port()));
1350 }
1351 urls.sort();
1352 urls.dedup();
1353 urls
1354}
1355
1356fn likely_lan_ip() -> Option<IpAddr> {
1357 let socket = std::net::UdpSocket::bind(("0.0.0.0", 0)).ok()?;
1358 socket.connect(("8.8.8.8", 80)).ok()?;
1359 let ip = socket.local_addr().ok()?.ip();
1360 (!ip.is_loopback()).then_some(ip)
1361}
1362
1363fn remote_html() -> &'static str {
1364 include_str!("../assets/remote.html")
1365}
1366
1367fn timer_phase_name(phase: TimerPhase) -> &'static str {
1368 match phase {
1369 TimerPhase::Normal => "normal",
1370 TimerPhase::Warning => "warning",
1371 TimerPhase::Overrun => "overrun",
1372 }
1373}
1374
1375fn draw_tool_name(tool: dais_core::state::DrawTool) -> &'static str {
1376 match tool {
1377 dais_core::state::DrawTool::Pen => "pen",
1378 dais_core::state::DrawTool::Highlighter => "highlighter",
1379 dais_core::state::DrawTool::Eraser => "eraser",
1380 }
1381}
1382
1383fn action_to_command(action: Action) -> Option<Command> {
1384 match action {
1385 Action::NextSlide => Some(Command::NextSlide),
1386 Action::PreviousSlide => Some(Command::PreviousSlide),
1387 Action::NextOverlay => Some(Command::NextOverlay),
1388 Action::PreviousOverlay => Some(Command::PreviousOverlay),
1389 Action::FirstSlide => Some(Command::FirstSlide),
1390 Action::LastSlide => Some(Command::LastSlide),
1391 Action::ToggleFreeze => Some(Command::ToggleFreeze),
1392 Action::ToggleBlackout => Some(Command::ToggleBlackout),
1393 Action::ToggleWhiteboard => Some(Command::ToggleWhiteboard),
1394 Action::ToggleLaser => Some(Command::ToggleLaser),
1395 Action::CycleLaserStyle => Some(Command::CycleLaserStyle),
1396 Action::ToggleInk => Some(Command::ToggleInk),
1397 Action::ClearInk => Some(Command::ClearInk),
1398 Action::CycleInkColor => Some(Command::CycleInkColor),
1399 Action::CycleInkWidth => Some(Command::CycleInkWidth),
1400 Action::ToggleSpotlight => Some(Command::ToggleSpotlight),
1401 Action::ToggleZoom => Some(Command::ToggleZoom),
1402 Action::ToggleOverview => Some(Command::ToggleSlideOverview),
1403 Action::ToggleNotes => Some(Command::ToggleNotesPanel),
1404 Action::ToggleNotesEdit => None,
1405 Action::StartPauseTimer => Some(Command::ToggleTimer),
1406 Action::ResetTimer => Some(Command::ResetTimer),
1407 Action::IncrementNotesFont => None,
1408 Action::DecrementNotesFont => None,
1409 Action::ToggleScreenShare => Some(Command::ToggleScreenShareMode),
1410 Action::TogglePresentationMode => Some(Command::TogglePresentationMode),
1411 Action::SwapDisplays => Some(Command::SwapDisplays),
1412 Action::ToggleTextBoxMode => None,
1413 Action::Quit => None,
1414 Action::SaveSidecar => None,
1415 Action::GoToSlide => None,
1416 }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use dais_core::bus::CommandBus;
1422 use dais_core::slide_group::SlideGroup;
1423 use dais_document::page::{PageDimensions, RenderedPage};
1424 use dais_document::source::{DocumentError, EmbeddedMetadata, OutlineEntry};
1425
1426 use super::*;
1427
1428 fn test_state() -> PresentationState {
1429 let mut state = PresentationState::new(
1430 3,
1431 vec![
1432 SlideGroup { logical_index: 0, pages: vec![0], notes: Some("hello".to_string()) },
1433 SlideGroup { logical_index: 1, pages: vec![1], notes: None },
1434 SlideGroup { logical_index: 2, pages: vec![2], notes: None },
1435 ],
1436 );
1437 state.current_page = 1;
1438 state.current_logical_slide = 1;
1439 state.blacked_out = true;
1440 state.timer.running = true;
1441 state.slide_ink_by_page.insert(
1442 1,
1443 vec![dais_core::state::InkStroke {
1444 points: vec![(0.1, 0.2), (0.8, 0.9)],
1445 color: [0, 110, 255, 255],
1446 width: 4.0,
1447 finished: true,
1448 }],
1449 );
1450 state.slide_text_boxes_by_page.insert(
1451 1,
1452 vec![dais_core::state::TextBox {
1453 id: 42,
1454 rect: (0.2, 0.3, 0.4, 0.1),
1455 content: "Remote text".to_string(),
1456 font_size: 24.0,
1457 color: [10, 20, 30, 255],
1458 background: Some([255, 255, 255, 200]),
1459 typst_prelude: "#set align(center)".to_string(),
1460 }],
1461 );
1462 state
1463 }
1464
1465 struct TestDoc;
1466
1467 impl DocumentSource for TestDoc {
1468 fn page_count(&self) -> usize {
1469 3
1470 }
1471
1472 fn page_dimensions(&self, _page_index: usize) -> PageDimensions {
1473 PageDimensions { width_pts: 16.0, height_pts: 9.0 }
1474 }
1475
1476 fn render_page(
1477 &self,
1478 _page_index: usize,
1479 _target_size: RenderSize,
1480 ) -> std::result::Result<RenderedPage, DocumentError> {
1481 Ok(RenderedPage { data: vec![255; 4], width: 1, height: 1 })
1482 }
1483
1484 fn embedded_metadata(&self) -> Option<EmbeddedMetadata> {
1485 None
1486 }
1487
1488 fn outline(&self) -> Option<Vec<OutlineEntry>> {
1489 None
1490 }
1491 }
1492
1493 fn test_doc() -> Arc<dyn DocumentSource> {
1494 Arc::new(TestDoc)
1495 }
1496
1497 fn test_server(sender: CommandSender) -> RemoteServer {
1498 start_server(
1499 ServerSettings {
1500 enabled: true,
1501 host: "127.0.0.1".to_string(),
1502 port: 0,
1503 token: "secret".to_string(),
1504 generated_token: false,
1505 allow_unauthenticated_loopback: true,
1506 },
1507 sender,
1508 Arc::new(RwLock::new(test_state())),
1509 test_doc(),
1510 )
1511 .unwrap()
1512 }
1513
1514 fn endpoint(server: &RemoteServer) -> RemoteEndpoint {
1515 RemoteEndpoint { host: "127.0.0.1".to_string(), port: server.addr().port(), token: None }
1516 }
1517
1518 fn headers(host: &str) -> HeaderMap {
1519 HeaderMap::from_iter([(HOST, host.parse().unwrap())])
1520 }
1521
1522 #[test]
1523 fn action_names_map_to_commands() {
1524 assert_eq!(command_for_action_name("next_slide"), Some(Command::NextSlide));
1525 assert_eq!(command_for_action_name("start_pause_timer"), Some(Command::ToggleTimer));
1526 assert_eq!(command_for_action_name("go_to_slide"), None);
1527 assert_eq!(command_for_action_name("quit"), None);
1528 assert_eq!(command_for_action_name("save_sidecar"), None);
1529 assert_eq!(command_for_action_name("toggle_notes_edit"), None);
1530 assert_eq!(command_for_action_name("missing"), None);
1531 }
1532
1533 #[test]
1534 fn state_dto_uses_stable_fields() {
1535 let dto = remote_state(&test_state());
1536 assert_eq!(dto.current_page, 1);
1537 assert_eq!(dto.current_logical_slide, 1);
1538 assert!(dto.blacked_out);
1539 assert!(dto.timer.running);
1540 assert_eq!(dto.current_notes.as_deref(), Some("hello"));
1541 assert_eq!(dto.ink_pen_color, [255, 0, 0, 255]);
1542 assert!((dto.ink_pen_width - 3.0).abs() < f32::EPSILON);
1543 assert_eq!(dto.ink_color_presets.len(), 0);
1544 assert_eq!(dto.ink_highlighter_color, [255, 220, 0, 100]);
1545 assert!((dto.ink_highlighter_width - 10.0).abs() < f32::EPSILON);
1546 assert_eq!(dto.ink_highlighter_color_presets.len(), 0);
1547 assert_eq!(dto.ink_strokes.len(), 1);
1548 assert_eq!(dto.ink_strokes[0].points, vec![[0.1, 0.2], [0.8, 0.9]]);
1549 assert_eq!(dto.ink_strokes[0].color, [0, 110, 255, 255]);
1550 assert!((dto.ink_strokes[0].width - 4.0).abs() < f32::EPSILON);
1551 assert_eq!(dto.text_boxes.len(), 1);
1552 assert_eq!(dto.text_boxes[0].id, 42);
1553 for (actual, expected) in dto.text_boxes[0].rect.iter().zip([0.2, 0.3, 0.4, 0.1]) {
1554 assert!((*actual - expected).abs() < f32::EPSILON);
1555 }
1556 assert_eq!(dto.text_boxes[0].content, "Remote text");
1557 assert!((dto.text_boxes[0].font_size - 24.0).abs() < f32::EPSILON);
1558 assert_eq!(dto.text_boxes[0].color, [10, 20, 30, 255]);
1559 assert_eq!(dto.text_boxes[0].background, Some([255, 255, 255, 200]));
1560 }
1561
1562 #[test]
1563 fn valid_action_dispatches_command() {
1564 let bus = CommandBus::new();
1565 let sender = bus.sender();
1566 let receiver = bus.into_receiver();
1567
1568 send_remote_action(&sender, "next_slide").unwrap();
1569
1570 assert_eq!(receiver.try_recv(), Some(Command::NextSlide));
1571 }
1572
1573 #[test]
1574 fn unknown_action_returns_error() {
1575 let bus = CommandBus::new();
1576 let sender = bus.sender();
1577
1578 assert!(send_remote_action(&sender, "not_real").is_err());
1579 }
1580
1581 #[test]
1582 fn http_action_route_dispatches_command() {
1583 let bus = CommandBus::new();
1584 let sender = bus.sender();
1585 let receiver = bus.into_receiver();
1586 let server = test_server(sender);
1587
1588 let response = client_action(&endpoint(&server), "next_slide").unwrap();
1589
1590 assert!(response.ok);
1591 assert_eq!(receiver.try_recv(), Some(Command::NextSlide));
1592 }
1593
1594 #[test]
1595 fn http_ink_stroke_dispatches_points_and_finishes() {
1596 let bus = CommandBus::new();
1597 let sender = bus.sender();
1598 let receiver = bus.into_receiver();
1599 let server = test_server(sender);
1600
1601 let response = client_ink_stroke(
1602 &endpoint(&server),
1603 &[[0.1, 0.2], [0.5, 0.5], [0.9, 0.8]],
1604 Some([255, 0, 0, 255]),
1605 Some(4.0),
1606 )
1607 .unwrap();
1608
1609 assert!(response.ok);
1610 assert_eq!(receiver.try_recv(), Some(Command::ToggleInk));
1612 assert_eq!(receiver.try_recv(), Some(Command::SetInkColor([255, 0, 0, 255])));
1613 assert_eq!(receiver.try_recv(), Some(Command::SetInkWidth(4.0)));
1614 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.1, 0.2)));
1615 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.5, 0.5)));
1616 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.9, 0.8)));
1617 assert_eq!(receiver.try_recv(), Some(Command::FinishInkStroke));
1618 assert_eq!(receiver.try_recv(), Some(Command::ToggleInk));
1619 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1620 }
1621
1622 #[test]
1623 fn http_ink_stroke_rejects_single_point() {
1624 let bus = CommandBus::new();
1625 let server = test_server(bus.sender());
1626
1627 let error = client_ink_stroke(&endpoint(&server), &[[0.5, 0.5]], None, None)
1628 .unwrap_err()
1629 .to_string();
1630
1631 assert!(error.contains("400"));
1632 }
1633
1634 #[test]
1635 fn http_ink_stroke_without_color_or_width_omits_set_commands() {
1636 let bus = CommandBus::new();
1637 let sender = bus.sender();
1638 let receiver = bus.into_receiver();
1639 let server = test_server(sender);
1640
1641 client_ink_stroke(&endpoint(&server), &[[0.1, 0.2], [0.9, 0.8]], None, None).unwrap();
1642
1643 assert_eq!(receiver.try_recv(), Some(Command::ToggleInk));
1644 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.1, 0.2)));
1645 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.9, 0.8)));
1646 assert_eq!(receiver.try_recv(), Some(Command::FinishInkStroke));
1647 assert_eq!(receiver.try_recv(), Some(Command::ToggleInk));
1648 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1649 }
1650
1651 #[test]
1652 fn http_ink_stroke_with_tool_dispatches_tool_before_style_and_points() {
1653 let bus = CommandBus::new();
1654 let sender = bus.sender();
1655 let receiver = bus.into_receiver();
1656 let server = test_server(sender);
1657
1658 let response = client_json_request(
1659 &endpoint(&server),
1660 "POST",
1661 "/api/v1/commands/ink/stroke",
1662 &serde_json::json!({
1663 "points": [[0.1, 0.2], [0.9, 0.8]],
1664 "tool": "highlighter",
1665 "color": [255, 220, 0, 100],
1666 "width": 12.0
1667 }),
1668 )
1669 .unwrap();
1670
1671 assert!(response.ok);
1672 assert_eq!(receiver.try_recv(), Some(Command::ToggleInk));
1673 assert_eq!(
1674 receiver.try_recv(),
1675 Some(Command::SetDrawTool(dais_core::state::DrawTool::Highlighter))
1676 );
1677 assert_eq!(receiver.try_recv(), Some(Command::SetInkColor([255, 220, 0, 100])));
1678 assert_eq!(receiver.try_recv(), Some(Command::SetInkWidth(12.0)));
1679 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.1, 0.2)));
1680 assert_eq!(receiver.try_recv(), Some(Command::AddInkPoint(0.9, 0.8)));
1681 assert_eq!(receiver.try_recv(), Some(Command::FinishInkStroke));
1682 assert_eq!(receiver.try_recv(), Some(Command::ToggleInk));
1683 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1684 }
1685
1686 #[test]
1687 fn http_ink_clear_dispatches_command() {
1688 let bus = CommandBus::new();
1689 let sender = bus.sender();
1690 let receiver = bus.into_receiver();
1691 let server = test_server(sender);
1692
1693 let response = client_ink_clear(&endpoint(&server)).unwrap();
1694
1695 assert!(response.ok);
1696 assert_eq!(receiver.try_recv(), Some(Command::ClearInk));
1697 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1698 }
1699
1700 #[test]
1701 fn http_text_box_place_dispatches_command() {
1702 let bus = CommandBus::new();
1703 let sender = bus.sender();
1704 let receiver = bus.into_receiver();
1705 let server = test_server(sender);
1706
1707 let response = client_json_request(
1708 &endpoint(&server),
1709 "POST",
1710 "/api/v1/commands/text-boxes/place",
1711 &serde_json::json!({ "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4 }),
1712 )
1713 .unwrap();
1714
1715 assert!(response.ok);
1716 assert_eq!(
1717 receiver.try_recv(),
1718 Some(Command::PlaceTextBox { x: 0.1, y: 0.2, w: 0.3, h: 0.4 })
1719 );
1720 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1721 }
1722
1723 #[test]
1724 fn http_text_box_editing_routes_dispatch_commands() {
1725 let bus = CommandBus::new();
1726 let sender = bus.sender();
1727 let receiver = bus.into_receiver();
1728 let server = test_server(sender);
1729
1730 client_json_request(
1731 &endpoint(&server),
1732 "POST",
1733 "/api/v1/commands/text-boxes/select",
1734 &serde_json::json!({ "id": 42 }),
1735 )
1736 .unwrap();
1737 client_json_request(
1738 &endpoint(&server),
1739 "POST",
1740 "/api/v1/commands/text-boxes/content",
1741 &serde_json::json!({ "id": 42, "content": "Updated" }),
1742 )
1743 .unwrap();
1744 client_json_request(
1745 &endpoint(&server),
1746 "POST",
1747 "/api/v1/commands/text-boxes/move",
1748 &serde_json::json!({ "id": 42, "x": 0.4, "y": 0.5 }),
1749 )
1750 .unwrap();
1751 client_json_request(
1752 &endpoint(&server),
1753 "POST",
1754 "/api/v1/commands/text-boxes/resize",
1755 &serde_json::json!({ "id": 42, "w": 0.2, "h": 0.3 }),
1756 )
1757 .unwrap();
1758 client_json_request(
1759 &endpoint(&server),
1760 "POST",
1761 "/api/v1/commands/text-boxes/delete",
1762 &serde_json::json!({ "id": 42 }),
1763 )
1764 .unwrap();
1765
1766 assert_eq!(receiver.try_recv(), Some(Command::SelectTextBox(42)));
1767 assert_eq!(
1768 receiver.try_recv(),
1769 Some(Command::EditTextBoxContent { id: 42, content: "Updated".to_string() })
1770 );
1771 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1772 assert_eq!(receiver.try_recv(), Some(Command::MoveTextBox { id: 42, x: 0.4, y: 0.5 }));
1773 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1774 assert_eq!(receiver.try_recv(), Some(Command::ResizeTextBox { id: 42, w: 0.2, h: 0.3 }));
1775 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1776 assert_eq!(receiver.try_recv(), Some(Command::DeleteTextBox { id: 42 }));
1777 assert_eq!(receiver.try_recv(), Some(Command::SaveSidecar));
1778 }
1779
1780 #[test]
1781 fn http_goto_rejects_zero_slide() {
1782 let bus = CommandBus::new();
1783 let sender = bus.sender();
1784 let receiver = bus.into_receiver();
1785 let server = test_server(sender);
1786
1787 let error = client_goto(&endpoint(&server), 0).unwrap_err().to_string();
1788
1789 assert!(error.contains("400"));
1790 assert_eq!(receiver.try_recv(), None);
1791 }
1792
1793 #[test]
1794 fn web_remote_route_serves_html() {
1795 let bus = CommandBus::new();
1796 let server = test_server(bus.sender());
1797
1798 let response = reqwest::blocking::get(format!("http://{}/remote", server.addr())).unwrap();
1799 let content_type =
1800 response.headers().get("content-type").unwrap().to_str().unwrap().to_string();
1801 let body = response.text().unwrap();
1802
1803 assert!(content_type.starts_with("text/html"));
1804 assert!(body.contains("Dais Remote"));
1805 }
1806
1807 #[test]
1808 fn slide_png_route_renders_image() {
1809 let bus = CommandBus::new();
1810 let server = test_server(bus.sender());
1811
1812 let response =
1813 reqwest::blocking::get(format!("http://{}/api/v1/slides/current.png", server.addr()))
1814 .unwrap();
1815 let content_type =
1816 response.headers().get("content-type").unwrap().to_str().unwrap().to_string();
1817 let body = response.bytes().unwrap();
1818
1819 assert_eq!(content_type, "image/png");
1820 assert!(body.starts_with(b"\x89PNG"));
1821 }
1822
1823 #[test]
1824 fn text_box_svg_route_renders_typst_image() {
1825 let bus = CommandBus::new();
1826 let server = test_server(bus.sender());
1827
1828 let response = reqwest::blocking::get(format!(
1829 "http://{}/api/v1/text-boxes/42/svg?w=180&h=60&slide_w=960&slide_h=540",
1830 server.addr()
1831 ))
1832 .unwrap();
1833 let content_type =
1834 response.headers().get("content-type").unwrap().to_str().unwrap().to_string();
1835 let body = response.text().unwrap();
1836
1837 assert_eq!(content_type, "image/svg+xml");
1838 assert!(body.starts_with("<svg"));
1839 }
1840
1841 #[test]
1842 fn query_token_authorizes_request() {
1843 let settings = ServerSettings {
1844 enabled: true,
1845 host: "0.0.0.0".to_string(),
1846 port: 4317,
1847 token: "secret".to_string(),
1848 generated_token: false,
1849 allow_unauthenticated_loopback: false,
1850 };
1851 let headers = HeaderMap::new();
1852
1853 assert!(is_authorized(
1854 &headers,
1855 Some("token=secret"),
1856 "127.0.0.1:50000".parse().unwrap(),
1857 &settings
1858 ));
1859 }
1860
1861 #[test]
1862 fn loopback_peer_can_use_loopback_exemption_on_wildcard_bind() {
1863 let settings = ServerSettings {
1864 enabled: true,
1865 host: "0.0.0.0".to_string(),
1866 port: 4317,
1867 token: "secret".to_string(),
1868 generated_token: false,
1869 allow_unauthenticated_loopback: true,
1870 };
1871 let headers = HeaderMap::new();
1872
1873 assert!(is_authorized(&headers, None, "127.0.0.1:50000".parse().unwrap(), &settings));
1874 }
1875
1876 #[test]
1877 fn non_loopback_peer_requires_token_even_when_loopback_exemption_is_enabled() {
1878 let settings = ServerSettings {
1879 enabled: true,
1880 host: "0.0.0.0".to_string(),
1881 port: 4317,
1882 token: "secret".to_string(),
1883 generated_token: false,
1884 allow_unauthenticated_loopback: true,
1885 };
1886 let headers = HeaderMap::new();
1887
1888 assert!(!is_authorized(&headers, None, "192.168.1.50:50000".parse().unwrap(), &settings));
1889 }
1890
1891 #[test]
1892 fn wildcard_bind_urls_do_not_advertise_unspecified_address() {
1893 let urls = remote_urls("0.0.0.0:4317".parse().unwrap());
1894
1895 assert!(urls.iter().any(|url| url == "http://127.0.0.1:4317/remote"));
1896 assert!(!urls.iter().any(|url| url.contains("0.0.0.0")));
1897 }
1898
1899 #[test]
1900 fn host_header_rejects_unknown_hostname_even_on_matching_port() {
1901 let settings = ServerSettings {
1902 enabled: true,
1903 host: "127.0.0.1".to_string(),
1904 port: 4317,
1905 token: "secret".to_string(),
1906 generated_token: false,
1907 allow_unauthenticated_loopback: true,
1908 };
1909 let headers = headers("attacker.example:4317");
1910
1911 assert!(!host_header_allowed(&headers, &settings));
1912 }
1913
1914 #[test]
1915 fn host_header_allows_loopback_names_on_matching_port() {
1916 let settings = ServerSettings {
1917 enabled: true,
1918 host: "127.0.0.1".to_string(),
1919 port: 4317,
1920 token: "secret".to_string(),
1921 generated_token: false,
1922 allow_unauthenticated_loopback: true,
1923 };
1924 let headers = headers("localhost:4317");
1925
1926 assert!(host_header_allowed(&headers, &settings));
1927 }
1928
1929 #[test]
1930 fn wildcard_bind_allows_ip_literal_host_on_matching_port() {
1931 let settings = ServerSettings {
1932 enabled: true,
1933 host: "0.0.0.0".to_string(),
1934 port: 4317,
1935 token: "secret".to_string(),
1936 generated_token: false,
1937 allow_unauthenticated_loopback: true,
1938 };
1939 let headers = headers("192.168.50.25:4317");
1940
1941 assert!(host_header_allowed(&headers, &settings));
1942 }
1943
1944 #[test]
1945 fn custom_token_must_be_alphanumeric() {
1946 assert!(custom_token_is_valid("abcDEF123"));
1947 assert!(!custom_token_is_valid(""));
1948 assert!(!custom_token_is_valid("abc-123"));
1949 assert!(!custom_token_is_valid("abc_123"));
1950 assert!(!custom_token_is_valid("abc+123"));
1951 assert!(!custom_token_is_valid("abc/123"));
1952 assert!(!custom_token_is_valid("abc&123"));
1953 }
1954
1955 #[test]
1956 fn start_server_rejects_configured_token_that_is_not_alphanumeric() {
1957 let bus = CommandBus::new();
1958
1959 let result = start_server(
1960 ServerSettings {
1961 enabled: true,
1962 host: "127.0.0.1".to_string(),
1963 port: 0,
1964 token: "not-a-normal-code".to_string(),
1965 generated_token: false,
1966 allow_unauthenticated_loopback: true,
1967 },
1968 bus.sender(),
1969 Arc::new(RwLock::new(test_state())),
1970 test_doc(),
1971 );
1972 let error = match result {
1973 Ok(_) => panic!("expected invalid token to be rejected"),
1974 Err(error) => error.to_string(),
1975 };
1976
1977 assert!(error.contains("ASCII letters and digits"));
1978 }
1979
1980 #[test]
1981 fn generated_token_is_human_typeable() {
1982 let token = generate_token();
1983
1984 assert_eq!(token.len(), 9);
1985 assert_eq!(token.as_bytes()[4], b'-');
1986 assert_eq!(token.chars().filter(char::is_ascii_digit).count(), 8);
1987 assert!(generated_pairing_code_is_valid(&token));
1988 }
1989}