From 90d40e2ecc803e7c4e9aea653f7bbc3738300853 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 14:48:45 +0200 Subject: [PATCH] Show: refuse a show.start from that is not a slide show.start {from} turned a negative or fractional index into slide 0 and an index past the end into a show that opened on the end screen, over the command and the control channel alike. Both now refuse it, as the slide commands do since #1063; a hidden slide still plays. --- crates/ui-egui/src/control.rs | 9 ++++--- crates/ui-egui/src/lib.rs | 49 ++++++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 +- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8292321c..50393af8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -335,11 +335,12 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> let reading = p.get("reading").and_then(Value::as_bool).unwrap_or(false); if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { app.start_custom_show(name, reading); - } else if p.get("from").is_some() { - let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize; - app.start_show(from, reading); } else { - app.start_show(app.show_start_index(), reading); + let from = match app.show_from(p) { + Ok(from) => from.unwrap_or_else(|| app.show_start_index()), + Err(e) => return err(e), + }; + app.start_show(from, reading); } ok(Value::Null) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..e70e32b3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -952,11 +952,9 @@ impl SlideApp { "show.start" => { if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { self.start_custom_show(name, false); - } else if p.get("from").is_some() { - let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0); - self.start_show(from, false); } else { - self.start_show(self.show_start_index(), false); + let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); + self.start_show(from, false); } } "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), @@ -1072,6 +1070,21 @@ impl SlideApp { self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) } + /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of + /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or + /// opens on the end screen. Hidden slides are allowed, as in "from current slide". + pub(crate) fn show_from(&self, p: &Value) -> Result, String> { + let from = match p.get("from") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, + }; + let count = self.session.active().map_or(0, |d| d.doc.slides.len()); + if from >= count { + return Err(format!("no slide {from}")); + } + Ok(Some(from)) + } + pub fn start_show(&mut self, from: usize, reading: bool) { self.anim_preview = None; self.media.stop_owned(media::Owner::Editor); @@ -3353,6 +3366,34 @@ mod tests { assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); } + /// `show.start {from}` that names no slide of the deck is refused, over `run` and over the + /// control channel, instead of starting at slide 0 or on the end screen. A hidden slide is a + /// slide: From Current Slide plays it. + #[test] + fn show_start_refuses_a_from_that_is_not_a_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.hide", &json!({"index": 1, "hidden": true})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + let ctx = egui::Context::default(); + for from in [json!(-1), json!(1.5), json!("1"), json!(3), json!(u64::MAX)] { + assert!(app.run("show.start", json!({"from": from})).is_err(), "{from}"); + assert!(app.show.is_none(), "{from}"); + let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": from})); + let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!("{from}") }; + assert_eq!(answer["ok"], false, "{from}: {answer}"); + assert!(app.show.is_none(), "{from}"); + } + app.run("show.start", json!({"from": 1})).unwrap(); + assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.state.ended)), Some((1, false))); + app.show = None; + let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": 2, "reading": true})); + let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!() }; + assert_eq!(answer["ok"], true, "{answer}"); + assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.reading)), Some((2, true))); + } + #[test] fn a_swipe_during_the_show_turns_the_page() { let mut harness = flick_harness(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4ec7b72e..9edb40a9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -48,7 +48,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.screenshot` | `{path?}` | PNG of the whole window (base64 when no path) | | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | -| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | +| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order. A `from` that is not a slide of the deck (negative, not a whole number, past the end) is refused; a hidden slide plays | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`, or `{"path": "slide.png", "transparentBackground": true}` for a PNG without the slide background) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude