Skip to main content

dais_remote/
lib.rs

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/// CLI-friendly remote endpoint selection.
40#[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/// Runtime override values supplied by CLI flags.
54#[derive(Debug, Clone, Default)]
55pub struct RemoteOverrides {
56    pub enabled: bool,
57    pub host: Option<String>,
58    pub port: Option<u16>,
59}
60
61/// Effective server settings after config and CLI overrides.
62#[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
90/// Handle for the background remote API server.
91pub 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        // Do not join here: active browser remotes can hold SSE connections open,
133        // and the presenter console must remain authoritative over app shutdown.
134    }
135}
136
137/// Stable state DTO returned by the remote API.
138#[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        // ink_active is false in test state, so ToggleInk bookends the stroke
1611        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}