From 90058395b78be26bb7ea7b4914a9f113c28d979d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:36:45 +0100 Subject: [PATCH] WASM: open presenter view in a separate browser window (#729) (#730) * docs: track WASM presenter popout work (#729) * Fix presenter transport retries and initialize popup note fonts * Clarify optional font coverage and source-verified popup lifecycle --------- Co-authored-by: FredApps --- apps/deckcraft-web/Cargo.toml | 2 +- apps/deckcraft-web/src/main.rs | 2 + apps/deckcraft-web/src/presenter.rs | 303 +++++++++++++++++++ apps/deckcraft-web/src/web.rs | 17 ++ crates/engine/src/recovery.rs | 10 + crates/pdf/src/tests.rs | 3 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/presenter.rs | 450 ++++++++++++++++++++++++++++ crates/ui-egui/src/services.rs | 2 + crates/ui-egui/src/show.rs | 8 + crates/ui-egui/src/show/keys.rs | 21 +- crates/ui-egui/src/show/tests.rs | 60 ++++ crates/ui-egui/src/show/ui.rs | 85 +++++- docs/wasm-presenter-popout-729.md | 65 ++++ 14 files changed, 1018 insertions(+), 11 deletions(-) create mode 100644 apps/deckcraft-web/src/presenter.rs create mode 100644 crates/ui-egui/src/presenter.rs create mode 100644 docs/wasm-presenter-popout-729.md diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 7014421d..541985e5 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -27,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] } diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 6d2b7e94..2ce622e9 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -18,6 +18,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_arch = "wasm32")] +mod presenter; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/deckcraft-web/src/presenter.rs b/apps/deckcraft-web/src/presenter.rs new file mode 100644 index 00000000..1ec9a298 --- /dev/null +++ b/apps/deckcraft-web/src/presenter.rs @@ -0,0 +1,303 @@ +//! Same-origin popup transport. Each window starts its own eframe/WASM runner. +use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View}; +use std::{ + cell::RefCell, + collections::VecDeque, + rc::{Rc, Weak}, +}; +use wasm_bindgen::{JsCast, closure::Closure}; + +const MAX_MESSAGE: usize = 16 * 1024 * 1024; +const MAX_COMMANDS: usize = 64; +const PREFIX: &str = "#deckcraft-presenter="; +fn error(e: wasm_bindgen::JsValue) -> String { + format!("{e:?}") +} +fn origin() -> Result { + web_sys::window().ok_or("No browser window")?.location().origin().map_err(error) +} +pub fn token() -> Option { + let hash = web_sys::window()?.location().hash().ok()?; + let token = hash.strip_prefix(PREFIX)?; + (!token.is_empty() && token.len() < 128).then(|| token.to_owned()) +} +fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool { + event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref())) +} +fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> { + let text = serde_json::to_string(&value).map_err(|e| e.to_string())?; + if text.len() > MAX_MESSAGE { + return Err("Presenter message is too large".into()); + } + window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error) +} +struct State { + popup: Option, + token: String, + commands: VecDeque, + ready: bool, + refresh_images: bool, +} +pub struct BrowserHost { + state: Rc>, + origin: String, + listener: Closure, + button: Option<(web_sys::HtmlElement, Closure)>, + images: DeltaCache, +} +impl BrowserHost { + pub fn new(ctx: egui::Context) -> Result { + let window = web_sys::window().ok_or("No browser window")?; + let origin = origin()?; + let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true })); + let weak = Rc::downgrade(&state); + let expected = origin.clone(); + let listener = Closure::new(move |event: web_sys::MessageEvent| { + let Some(state) = weak.upgrade() else { return }; + let Ok(mut state) = state.try_borrow_mut() else { return }; + let Some(popup) = state.popup.as_ref() else { return }; + if !same_source(&event, popup, &expected) { + return; + } + let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return }; + let Ok(value) = serde_json::from_str::(&text) else { return }; + if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) { + return; + } + match value.get("kind").and_then(|v| v.as_str()) { + Some("ready") => { + state.ready = true; + state.refresh_images = true; + } + Some("command") if state.commands.len() < MAX_COMMANDS => { + if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) { + state.commands.push_back(command); + } + } + _ => {} + } + ctx.request_repaint(); + }); + window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?; + Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() }) + } + fn remove_button(&mut self) { + if let Some((button, callback)) = self.button.take() { + let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref()); + button.remove(); + } + } + fn recovery_button(&mut self) { + if self.button.is_some() { + return; + } + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let Some(body) = document.body() else { return }; + let Ok(element) = document.create_element("button") else { return }; + let Ok(button) = element.dyn_into::() else { return }; + button.set_text_content(Some("Open presenter window")); + let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer"); + let weak: Weak> = Rc::downgrade(&self.state); + let button_copy = button.clone(); + let callback = Closure::new(move |_event: web_sys::Event| { + if let Some(state) = weak.upgrade() { + match open_popup(&state) { + Ok(()) => { + button_copy.set_text_content(Some("Opening presenter window…")); + } + Err(_) => { + button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry.")); + } + } + } + }); + if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() { + self.button = Some((button, callback)); + } else { + let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref()); + button.remove(); + } + } +} +fn open_popup(state: &Rc>) -> Result<(), String> { + let window = web_sys::window().ok_or("No browser window")?; + let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?; + if let Some(popup) = &state.popup + && !popup.closed().unwrap_or(true) + { + let _ = popup.focus(); + return Ok(()); + } + let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random()); + let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?; + url.set_hash(&format!("{PREFIX}{token}")); + let popup = window + .open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700") + .map_err(error)? + .ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?; + if popup.closed().unwrap_or(true) { + return Err("Browser isolation prevented connecting the presenter window".into()); + } + state.popup = Some(popup); + state.token = token; + state.commands.clear(); + state.ready = false; + Ok(()) +} +impl Host for BrowserHost { + fn open(&mut self) -> Result<(), String> { + self.close(); + let result = open_popup(&self.state); + if result.is_err() { + self.recovery_button(); + } + result + } + fn is_open(&self) -> bool { + self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true)) + } + fn commands(&mut self) -> Vec { + self.state.borrow_mut().commands.drain(..).collect() + } + fn update(&mut self, snapshot: Snapshot) { + if !self.is_open() { + self.recovery_button(); + return; + } + self.remove_button(); + let mut state = self.state.borrow_mut(); + if !state.ready { + return; + } + if let Some(popup) = state.popup.clone() { + let token = state.token.clone(); + let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| { + send( + &popup, + &self.origin, + serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta, + "currentChanged":current_changed, "nextChanged":next_changed}), + ) + }); + if sent.is_ok() { + state.refresh_images = false; + } + } + } + fn close(&mut self) { + self.remove_button(); + let mut state = self.state.borrow_mut(); + if let Some(window) = state.popup.take() { + let _ = window.close(); + } + state.commands.clear(); + state.ready = false; + state.token.clear(); + } +} +impl Drop for BrowserHost { + fn drop(&mut self) { + self.close(); + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref()); + } + } +} + +pub struct Popup { + token: String, + origin: String, + opener: web_sys::Window, + snapshot: Rc>>, + last_received: Rc>, + listener: Closure, + view: View, +} +impl Popup { + pub fn new(token: String, ctx: egui::Context) -> Result { + let window = web_sys::window().ok_or("No browser window")?; + // `opener` is a WindowProxy from another realm; instanceof Window can fail across + // realms. The browser API supplies a WindowProxy or null, and messages still verify + // exact source identity and origin before any data is accepted. + let opener = window.opener().map_err(error)?; + if opener.is_null() || opener.is_undefined() { + return Err("The audience window is unavailable".into()); + } + let opener: web_sys::Window = opener.unchecked_into(); + let origin = origin()?; + let snapshot: Rc>> = Rc::new(RefCell::new(None)); + let last_received = Rc::new(RefCell::new(js_sys::Date::now())); + let received = last_received.clone(); + let target = snapshot.clone(); + let expected_origin = origin.clone(); + let expected_opener = opener.clone(); + let expected_token = token.clone(); + let listener = Closure::new(move |event: web_sys::MessageEvent| { + if !same_source(&event, &expected_opener, &expected_origin) { + return; + } + let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return }; + let Ok(value) = serde_json::from_str::(&text) else { return }; + if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str()) + || value.get("kind").and_then(|v| v.as_str()) != Some("snapshot") + { + return; + } + if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(previous) = target.borrow().as_ref() { + if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) { + data.current = previous.current.clone(); + } + if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) { + data.next = previous.next.clone(); + } + } + *target.borrow_mut() = Some(data); + *received.borrow_mut() = js_sys::Date::now(); + ctx.request_repaint(); + } + }); + window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?; + if let Some(document) = window.document() { + document.set_title("Presenter View — DeckCraft"); + } + let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() }; + send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?; + Ok(popup) + } +} +impl eframe::App for Popup { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let snapshot = self.snapshot.borrow(); + let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str()); + if !self.view.prepare(ui.ctx(), notes) { + return; + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + if self.opener.closed().unwrap_or(true) { + ui.heading("The audience window has closed."); + return; + } + if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 { + ui.heading("The audience window is not responding."); + ui.label("Return to the audience window to resume the connection."); + return; + } + egui::CentralPanel::default().show(ui, |ui| { + if let Some(snapshot) = snapshot.as_ref() { + for command in self.view.ui(ui, snapshot) { + let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command})); + } + } else { + ui.label("Waiting for the audience window…"); + } + }); + } +} +impl Drop for Popup { + fn drop(&mut self) { + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref()); + } + } +} diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f8e68a35..a385d44c 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -39,6 +39,10 @@ pub fn start() { log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend); } deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); + if let Some(token) = crate::presenter::token() { + let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?; + return Ok(Box::new(presenter) as Box); + } let inbox: Inbox = Inbox::default(); let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); let q = query(); @@ -80,6 +84,11 @@ impl eframe::App for WebShell { /// Delegate one eframe logic pass to the wrapped application. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.app.logic(ctx); + if self.app.show.is_none() + && let Some(host) = self.app.services.presenter.as_mut() + { + host.close(); + } } /// Consume browser drops with fresh input, capture the identity before the asynchronous read, @@ -118,7 +127,15 @@ impl eframe::App for WebShell { /// back to `open_async` (browser picker → inbox) and `download`. fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); + let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) { + Ok(host) => Some(Box::new(host) as Box), + Err(error) => { + log::error!("Presenter window: {error}"); + None + } + }; Services { + presenter, open_async: Some(Box::new(move |purpose: &str, request: u64| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 6c5c0230..d8c9f23e 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -259,6 +259,16 @@ mod tests { dir } + #[test] + fn instance_identity_is_stable_nonzero_and_json_safe() { + let id = instance_id(); + assert_eq!(instance_id(), id); + assert!(id != 0 && id <= UID_MASK); + assert_eq!(id & 0xFF_FFFF, 0); + let encoded = serde_json::to_value(id).unwrap(); + assert_eq!(encoded.as_f64().unwrap() as u64, id); + } + #[test] fn recovers_unsaved_presentations() { let dir = temp("recovery"); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8bdbbce9..b34e963e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -202,7 +202,8 @@ fn unsafe_url_links_are_not_exported() { /// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF /// embedding. No font bytes or vendor assets are stored as test fixtures. fn font_without_head() -> Vec { - let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec(); + // The final bundled face is always Ubuntu Light; a system lookup may select a variable font. + let mut bytes = deckcraft_fonts::bundled().last().unwrap().to_vec(); for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] { let mut offset = 0; while offset + old.len() <= bytes.len() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b4e531ba..566dffab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod icons; pub mod media; pub mod menus; pub mod opening; +pub mod presenter; pub mod ribbon; pub mod settings; pub mod show; diff --git a/crates/ui-egui/src/presenter.rs b/crates/ui-egui/src/presenter.rs new file mode 100644 index 00000000..95569778 --- /dev/null +++ b/crates/ui-egui/src/presenter.rs @@ -0,0 +1,450 @@ +//! Bounded presenter-window messages. The audience owns the show; the remote window +//! receives previews and sends input, never a second independently advancing show. +use serde::{Deserialize, Serialize}; + +pub const MAX_PREVIEW_SIDE: usize = 640; +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Preview { + pub width: usize, + pub height: usize, + pub rgba: Vec, +} +impl Preview { + pub fn from_image(image: &egui::ColorImage) -> Option { + let [w, h] = image.size; + if w == 0 || h == 0 || w.checked_mul(h)? != image.pixels.len() { + return None; + } + let scale = (MAX_PREVIEW_SIDE as f64 / w.max(h) as f64).min(1.0); + let width = ((w as f64 * scale) as usize).max(1); + let height = ((h as f64 * scale) as usize).max(1); + let mut rgba = Vec::with_capacity(width * height * 4); + for y in 0..height { + for x in 0..width { + let pixel = image.pixels.get((y * h / height) * w + x * w / width)?; + rgba.extend_from_slice(&pixel.to_srgba_unmultiplied()); + } + } + Some(Self { width, height, rgba }) + } + pub fn image(&self) -> Option { + if self.width == 0 + || self.height == 0 + || self.width > MAX_PREVIEW_SIDE + || self.height > MAX_PREVIEW_SIDE + || self.width.checked_mul(self.height)?.checked_mul(4)? != self.rgba.len() + { + return None; + } + Some(egui::ColorImage::from_rgba_unmultiplied([self.width, self.height], &self.rgba)) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Snapshot { + pub slide: usize, + pub total: usize, + pub ended: bool, + pub notes: String, + pub elapsed: String, + pub paused: bool, + pub notes_px: f32, + pub blank: Option, + pub grid: bool, + pub zoom_armed: bool, + pub current: Option, + pub next: Option, +} + +/// Images acknowledged by a successful transport send, never merely attempted images. +#[derive(Default)] +pub struct DeltaCache { + current: Option, + next: Option, + token: String, +} +impl DeltaCache { + pub fn send( + &mut self, + mut snapshot: Snapshot, + token: &str, + refresh: bool, + send: impl FnOnce(Snapshot, bool, bool) -> Result<(), E>, + ) -> Result<(), E> { + let fresh = refresh || self.token != token; + let current_changed = fresh || self.current != snapshot.current; + let next_changed = fresh || self.next != snapshot.next; + let current = snapshot.current.clone(); + let next = snapshot.next.clone(); + if !current_changed { + snapshot.current = None; + } + if !next_changed { + snapshot.next = None; + } + send(snapshot, current_changed, next_changed)?; + self.current = current; + self.next = next; + self.token = token.to_owned(); + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Command { + Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool }, + Zoom { x: f32, y: f32 }, + End, +} +/// Implemented by the browser host. Native hosts retain the existing egui viewport. +pub trait Host { + fn open(&mut self) -> Result<(), String>; + fn is_open(&self) -> bool; + fn commands(&mut self) -> Vec; + fn update(&mut self, snapshot: Snapshot); + fn close(&mut self); +} + +/// The popup's independent egui renderer. It never advances a local show clock. +#[derive(Default)] +pub struct View { + fonts: crate::uifonts::UiFonts, + styled: bool, + current: Option, + next: Option, + previous_current: Option, + previous_next: Option, +} +impl View { + /// Install the same interface and dynamic fallback fonts as SlideApp before layout. + /// Font definitions take effect on the next pass; never forward input on a discarded pass. + pub fn prepare(&mut self, ctx: &egui::Context, notes: &str) -> bool { + if !self.styled { + crate::theme::install_fonts_with(ctx, &self.fonts.faces); + crate::theme::apply(ctx, &crate::theme::Tokens::dark()); + self.styled = true; + ctx.request_discard("install presenter interface fonts before layout"); + return false; + } + if ctx.will_discard() { + return false; + } + self.fonts.note(ctx, notes); + if self.fonts.apply(ctx) { + ctx.request_discard("install presenter note fallback fonts before layout"); + return false; + } + true + } + + pub fn ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot) -> Vec { + if !self.prepare(ui.ctx(), &snapshot.notes) { + return Vec::new(); + } + let mut commands = Vec::new(); + let keys = ui.input(|i| { + i.events + .iter() + .filter_map(|event| if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { Some((*key, *modifiers)) } else { None }) + .collect::>() + }); + for (key, modifiers) in keys { + // Consume once before widgets see Space/Enter, avoiding a second button activation. + ui.input_mut(|i| i.consume_key(modifiers, key)); + commands.push(Command::Key { + name: key.name().into(), + command: modifiers.command, + shift: modifiers.shift, + alt: modifiers.alt, + ctrl: modifiers.ctrl, + }); + } + fn update( + ctx: &egui::Context, + name: &str, + previous: &mut Option, + texture: &mut Option, + image: &Option, + ) { + if previous == image { + return; + } + *previous = image.clone(); + *texture = image.as_ref().and_then(Preview::image).map(|image| ctx.load_texture(name, image, egui::TextureOptions::LINEAR)); + } + update(ui.ctx(), "remote-current", &mut self.previous_current, &mut self.current, &snapshot.current); + update(ui.ctx(), "remote-next", &mut self.previous_next, &mut self.next, &snapshot.next); + let key = |name: &str| Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false }; + ui.horizontal_wrapped(|ui| { + ui.heading(&snapshot.elapsed); + ui.label(format!("Slide {} of {}", snapshot.slide.saturating_add(1), snapshot.total)); + for (label, name) in [("◀ Previous", "ArrowLeft"), ("Next ▶", "ArrowRight")] { + if ui.button(label).clicked() { + commands.push(key(name)); + } + } + if ui.button("End Show").clicked() { + commands.push(Command::End); + } + }); + ui.horizontal_wrapped(|ui| { + for (label, name) in [ + (if snapshot.paused { "Resume" } else { "Pause" }, "T"), + ("Reset", "R"), + ("Black", "B"), + ("White", "W"), + ("All slides", "G"), + ("Zoom", "Z"), + ] { + if ui.button(label).clicked() { + commands.push(key(name)); + } + } + }); + ui.separator(); + ui.columns(2, |columns| { + let left = &mut columns[0]; + if snapshot.ended { + left.heading("End of slide show"); + } else if let Some(texture) = &self.current { + let response = left.add( + egui::Image::new(texture) + .fit_to_exact_size(fit_preview(texture.size_vec2(), left.available_width(), left.available_height() * 0.8)) + .sense(egui::Sense::click()), + ); + if let Some(blank) = &snapshot.blank { + let color = if blank == "white" { egui::Color32::WHITE } else { egui::Color32::BLACK }; + left.painter().rect_filled(response.rect, 0.0, color); + } + if snapshot.zoom_armed + && response.clicked() + && let Some(point) = response.interact_pointer_pos() + { + commands.push(Command::Zoom { + x: (point.x - response.rect.left()) / response.rect.width().max(1.0), + y: (point.y - response.rect.top()) / response.rect.height().max(1.0), + }); + } + } + if snapshot.zoom_armed { + left.label("Click the preview to zoom."); + } + if snapshot.grid { + left.label("Choose a slide in the audience window, or press G to return."); + } + let right = &mut columns[1]; + right.label("Next"); + if let Some(texture) = &self.next { + right.add(egui::Image::new(texture).fit_to_exact_size(fit_preview( + texture.size_vec2(), + right.available_width(), + right.available_height() * 0.45, + ))); + } else { + right.label("End of slide show"); + } + right.horizontal(|ui| { + ui.label("Notes"); + if ui.button("A−").clicked() { + commands.push(key(egui::Key::OpenBracket.name())); + } + if ui.button("A+").clicked() { + commands.push(key(egui::Key::CloseBracket.name())); + } + }); + egui::ScrollArea::vertical().show(right, |ui| { + ui.label( + egui::RichText::new(if snapshot.notes.is_empty() { "No notes." } else { &snapshot.notes }) + .size(snapshot.notes_px.clamp(10.0, 72.0)), + ); + }); + }); + commands + } +} +fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 { + size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot() -> Snapshot { + Snapshot { + slide: 0, + total: 2, + ended: false, + notes: String::new(), + elapsed: "00:00:00".into(), + paused: false, + notes_px: 18.0, + blank: None, + grid: false, + zoom_armed: false, + current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }), + next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }), + } + } + + #[test] + fn failed_transport_retries_images_and_only_success_deduplicates() { + let mut cache = DeltaCache::default(); + let first = snapshot(); + let failed = cache.send(first.clone(), "session", false, |delta, current, next| { + assert!(current && next); + assert_eq!(delta.current, first.current); + assert_eq!(delta.next, first.next); + Err("serialization or postMessage failed") + }); + assert!(failed.is_err()); + let sent = cache.send(first.clone(), "session", false, |delta, current, next| { + assert!(current && next, "a failed initial send must not suppress either image"); + assert_eq!(delta.current, first.current); + assert_eq!(delta.next, first.next); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + let sent = cache.send(first.clone(), "session", false, |delta, current, next| { + assert!(!current && !next); + assert!(delta.current.is_none() && delta.next.is_none()); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + let mut changed = first.clone(); + changed.current.as_mut().unwrap().rgba = vec![42; 4]; + let failed = cache.send(changed.clone(), "session", false, |_, current, next| { + assert!(current && !next); + Err(()) + }); + assert!(failed.is_err()); + let sent = cache.send(changed.clone(), "session", false, |delta, current, next| { + assert!(current && !next, "unchanged retry after a failed changed-image send"); + assert_eq!(delta.current, changed.current); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + } + + #[test] + fn transport_refreshes_new_sessions_reloads_and_clears_previews() { + let mut cache = DeltaCache::default(); + for (token, refresh) in [("first", false), ("second", false), ("second", true)] { + let sent = cache.send(snapshot(), token, refresh, |delta, current, next| { + assert!(current && next); + assert!(delta.current.is_some() && delta.next.is_some()); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + } + let failed = cache.send(snapshot(), "second", true, |_, current, next| { + assert!(current && next); + Err(()) + }); + // BrowserHost retains refresh_images after failure, even when the token is unchanged. + let refresh_images = failed.is_err(); + let sent = cache.send(snapshot(), "second", refresh_images, |_, current, next| { + assert!(current && next, "reload refresh must survive a failed send"); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + let mut cleared = snapshot(); + cleared.current = None; + cleared.next = None; + let failed = cache.send(cleared.clone(), "second", false, |_, current, next| { + assert!(current && next); + Err(()) + }); + assert!(failed.is_err()); + let sent = cache.send(cleared.clone(), "second", false, |delta, current, next| { + assert!(current && next, "clearing images is retried too"); + assert!(delta.current.is_none() && delta.next.is_none()); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + let sent = cache.send(cleared, "second", false, |_, current, next| { + assert!(!current && !next); + Ok::<_, ()>(()) + }); + assert!(sent.is_ok()); + } + + #[test] + fn popup_installs_interface_fonts_before_non_latin_note_layout() { + let ctx = egui::Context::default(); + let mut view = View::default(); + let mut data = snapshot(); + let arabic = "\u{645}\u{631}\u{62d}\u{628}\u{627}"; + let japanese = "\u{65e5}\u{672c}\u{8a9e}"; + let korean = "\u{d55c}\u{ae00}"; + data.notes = format!("{arabic} {japanese} {korean}"); + let mut installing = 0; + let mut layouts = 0; + for _ in 0..4 { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + if !view.prepare(ui.ctx(), &data.notes) { + installing += 1; + assert!(view.ui(ui, &data).is_empty(), "no commands during a discarded font-install pass"); + return; + } + assert!(!ui.ctx().will_discard()); + layouts += 1; + for (family, text) in [("Noto Sans Arabic", arabic), ("BIZ UDPGothic", japanese), ("NanumGothic", korean)] { + if deckcraft_fonts::CRAFT_FONTS.iter().any(|face| face.family == family) { + ui.ctx().fonts_mut(|fonts| { + for c in text.chars() { + assert!(fonts.has_glyph(&egui::FontId::proportional(18.0), c), "missing bundled {family} glyph {c:?}"); + } + }); + } + } + let _ = view.ui(ui, &data); + }); + output.textures_delta.clear(); + } + assert!(installing > 0 && layouts > 0, "font installation must precede an actual notes layout"); + } + #[test] + fn popup_input_is_forwarded_once_without_advancing_its_own_state() { + use egui_kittest::kittest::Queryable; + let snapshot = Snapshot { + slide: 0, + total: 3, + ended: false, + notes: "Synthetic notes".into(), + elapsed: "00:00:00".into(), + paused: false, + notes_px: 18.0, + blank: None, + grid: false, + zoom_armed: false, + current: None, + next: None, + }; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, (view, snapshot, sent): &mut (View, Snapshot, Vec)| { + sent.extend(view.ui(ui, snapshot)); + }, + (View::default(), snapshot, Vec::new()), + ); + h.run_steps(2); + h.get_by_label("Next ▶").click(); + h.run_steps(2); + assert_eq!(h.state().2.len(), 1); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert_eq!(h.state().2.len(), 2, "Space must not also activate the focused Next button"); + assert_eq!(h.state().0.previous_current, None); + assert_eq!(h.state().1.slide, 0, "only an audience snapshot changes the displayed position"); + assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name())); + } + + #[test] + fn previews_are_bounded_and_bad_dimensions_are_rejected() { + let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED); + let p = Preview::from_image(&image).unwrap(); + assert_eq!((p.width, p.height), (640, 360)); + assert_eq!(p.image().unwrap().pixels[0], egui::Color32::RED); + for (width, height, rgba) in [(usize::MAX, 1, vec![]), (0, 1, vec![]), (1, 1, vec![0; 3]), (641, 1, vec![0; 2564])] { + assert!(Preview { width, height, rgba }.image().is_none()); + } + } +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 88f03ac2..fc8eb5d9 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -36,6 +36,8 @@ pub type Inbox = std::sync::Arc>>; /// Platform services injected by the host (desktop or web). #[derive(Default)] pub struct Services { + /// Browser-owned presenter popup; absent on native hosts. + pub presenter: Option>, /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. pub pick_open: Option, /// Save dialog with a suggested name → path. diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1896e3be..a8521d3d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -63,6 +63,10 @@ pub struct Show { /// Preview renders started on another thread. #[cfg(test)] preview_spawns: usize, + remote_preview: Option, + remote_next: Option<(usize, crate::presenter::Preview)>, + remote_sent: f64, + remote_opened: bool, /// The next slide's first frame, rendered in the background while this one is shown so /// advancing only uploads it (#240). prefetch: Option, @@ -231,6 +235,10 @@ impl Show { next_job: None, #[cfg(test)] preview_spawns: 0, + remote_preview: None, + remote_next: None, + remote_sent: f64::NEG_INFINITY, + remote_opened: false, prefetch: None, backdrop: None, blank: None, diff --git a/crates/ui-egui/src/show/keys.rs b/crates/ui-egui/src/show/keys.rs index c7da76e2..77741866 100644 --- a/crates/ui-egui/src/show/keys.rs +++ b/crates/ui-egui/src/show/keys.rs @@ -12,6 +12,22 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) .collect() }); + let (keep, consumed) = handle_keys(show, doc, now, keys, presenter); + for (key, modifiers) in consumed { + ctx.input_mut(|i| i.consume_key(modifiers, key)); + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + keep +} + +pub(super) fn handle_keys( + show: &mut Show, + doc: &Presentation, + now: f64, + keys: Vec<(egui::Key, egui::Modifiers)>, + presenter: bool, +) -> (bool, Vec<(egui::Key, egui::Modifiers)>) { + let mut consumed = vec![]; let mut keep = true; let mut actions = vec![]; for (k, m) in keys { @@ -31,15 +47,14 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, } // The same key must not also activate a focused presenter button or be handled again // by an embedded presenter viewport. - ctx.input_mut(|i| i.consume_key(m, k)); - ctx.request_repaint_of(egui::ViewportId::ROOT); + consumed.push((k, m)); } for action in actions { if !show.apply(action, doc, now) { keep = false; } } - keep + (keep, consumed) } /// Whether the grid that the presenter key G opens is showing in this viewport. diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index a1069a5b..23909fe4 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1123,6 +1123,66 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() { assert_ne!(show.input_state(), base, "a blank screen has no slide click targets"); } +#[test] +fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { + use crate::presenter::{Command, Host, Snapshot}; + use std::{cell::RefCell, rc::Rc}; + #[derive(Default)] + struct Bridge { + opens: usize, + closes: usize, + commands: Vec, + } + struct Mock(Rc>); + impl Host for Mock { + fn open(&mut self) -> Result<(), String> { + self.0.borrow_mut().opens += 1; + Ok(()) + } + fn is_open(&self) -> bool { + true + } + fn commands(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut().commands) + } + fn update(&mut self, _: Snapshot) {} + fn close(&mut self) { + self.0.borrow_mut().closes += 1; + } + } + let bridge = Rc::new(RefCell::new(Bridge::default())); + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let services = crate::Services { presenter: Some(Box::new(Mock(bridge.clone()))), ..Default::default() }; + let mut app = SlideApp::new(session, services); + app.ui.presenter_view = true; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().start_show(0, false); + h.run_steps(2); + assert_eq!(bridge.borrow().opens, 1); + for (key, slide) in [(egui::Key::ArrowRight, 1), (egui::Key::ArrowLeft, 0), (egui::Key::End, 2), (egui::Key::Home, 0)] { + bridge.borrow_mut().commands.push(Command::Key { name: key.name().into(), command: false, shift: false, alt: false, ctrl: false }); + h.run_steps(2); + assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide); + } + bridge.borrow_mut().commands.push(Command::End); + h.run_steps(2); + assert!(h.state().show.is_none()); + assert_eq!(bridge.borrow().closes, 1); + h.state_mut().start_show(1, false); + h.run_steps(2); + assert_eq!(bridge.borrow().opens, 2); + assert_eq!(h.state().show.as_ref().unwrap().state.slide, 1); +} + /// #964: the next-slide preview renders on another thread; the presenter frame that asks for /// it doesn't wait, and the preview arrives on a later frame. #[test] diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 71107f94..a0a1fe29 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -1,8 +1,8 @@ -use super::keys::keyboard; +use super::keys::{handle_keys, keyboard}; use super::media::{ Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media, }; -use super::presenter::{PresenterTimer, presenter_window}; +use super::presenter::{PresenterTimer, format_elapsed, presenter_window}; use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch}; use super::zoom::{ Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv, @@ -58,6 +58,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { app.show = None; + if let Some(host) = app.services.presenter.as_mut() { + host.close(); + } return; }; let now = ctx.input(|i| i.time); @@ -71,8 +74,35 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { return; } show.observe_visible_frame(now, web_time::Instant::now()); - begin_show(&ctx, &mut show, &doc, now); + begin_show(&ctx, &mut show, &doc, now, app.services.presenter.is_some()); let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] }; + if show.presenter + && !show.reading + && let Some(host) = app.services.presenter.as_mut() + { + if !show.remote_opened { + show.remote_opened = true; + if let Err(message) = host.open() { + app.status = Some((message, crate::now_ms())); + } + } + for command in host.commands() { + match command { + crate::presenter::Command::End => out.keep = false, + crate::presenter::Command::Zoom { x, y } if x.is_finite() && y.is_finite() && show.zoom_arm => { + show.zoom = Some(Zoom::at(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0))); + show.zoom_arm = false; + } + crate::presenter::Command::Key { name, command, shift, alt, ctrl } => { + if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) { + let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl }; + out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0; + } + } + _ => {} + } + } + } let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); if moved { show.last_move = now; @@ -94,6 +124,43 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { draw_pen_and_hints(ui, &mut show, &f, &painter, pointing); draw_bars(ui, &mut show, &f, &painter, &mut out); draw_extras(&mut show, &f, &painter, &mut out); + if out.keep + && show.presenter + && !show.reading + && let Some(host) = app.services.presenter.as_mut() + { + if now - show.remote_sent >= 0.2 { + let next = show.state.peek_next(&doc); + if let Some(n) = next + && show.remote_next.as_ref().is_none_or(|(old, _)| *old != n) + { + let size = + crate::textures::fit_size((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), 640); + let image = deckcraft_render::render_slide( + &doc, + n, + &RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() }, + ); + show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image)); + } + host.update(crate::presenter::Snapshot { + slide: show.state.slide, + total: doc.slides.len(), + ended: show.ended, + notes: doc.slides.get(show.state.slide).map(|s| s.notes_text()).unwrap_or_default(), + elapsed: format_elapsed(show.timer.elapsed(now)), + paused: show.timer.paused(), + notes_px: show.notes_px, + blank: show.blank_name().map(str::to_owned), + grid: show.grid, + zoom_armed: show.zoom_arm, + current: show.remote_preview.clone(), + next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()), + }); + show.remote_sent = now; + } + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } finish(app, show, &f, out.keep); } @@ -110,7 +177,7 @@ fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) { } } -fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) { +fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, remote: bool) { if !show.started { show.started = true; show.slide_start = now; @@ -121,7 +188,7 @@ fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64 show.show_start = now; show.timer = PresenterTimer::start(now); show.last_move = now; - if show_fills_screen(show.reading, &doc.show.show_type) { + if show_fills_screen(show.reading, &doc.show.show_type) && !(show.presenter && remote) { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); } show.after_enter(doc, now); @@ -303,6 +370,9 @@ fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option { render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref()) } }; + if show.remote_opened && show.presenter { + show.remote_preview = crate::presenter::Preview::from_image(&ci); + } let tex = match show.tex.take() { Some((_, mut h)) => { crate::textures::replace(ctx, &mut h, ci); @@ -730,7 +800,7 @@ fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out) ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // Presenter view in a second window. - if out.keep && show.presenter && !show.reading { + if out.keep && show.presenter && !show.reading && !show.remote_opened { presenter_window(ctx, show, doc, now, &mut out.keep); } } @@ -740,6 +810,9 @@ fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) { if keep { app.show = Some(show); } else { + if let Some(host) = app.services.presenter.as_mut() { + host.close(); + } app.media.stop_owned(crate::media::Owner::Show); if !show.reading { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); diff --git a/docs/wasm-presenter-popout-729.md b/docs/wasm-presenter-popout-729.md new file mode 100644 index 00000000..0ee3f497 --- /dev/null +++ b/docs/wasm-presenter-popout-729.md @@ -0,0 +1,65 @@ +# WASM presenter-view popout (#729) + +Tracks https://github.com/storytold/deckcraft/issues/729 (the reporter moved duplicate #726 here). + +## Implementation + +The browser presenter opens the same DeckCraft web build in a separate, same-origin window. +That window starts its own eframe runner and renders only presenter controls, current/next +previews and notes. The audience window remains the sole owner of slideshow state, animations, +media and navigation. Presenter keyboard input uses the same navigation handler as the native +presenter, so an input is applied once rather than advancing two independent shows. + +Messages check the exact origin, source window and a per-opening session identifier. Input +queues and preview dimensions are bounded. Previews use the audience's rendered current frame; +unchanged preview pixels are not retransmitted. Presenter updates are limited to five per second. + +If the browser blocks the initial popup, an explicit Open presenter window button calls the +browser API directly from its click handler. Browser policy can still require the user to allow +popups, or choose to open a tab instead of a window. Closing the presenter leaves the audience +running and offers the same button to reopen it. Ending the show closes the presenter. The +presenter reports a closed or unresponsive audience rather than continuing an independent show. +Native presenter view continues to use its existing egui viewport. + +No native second-display placement, Linux display-counting policy, laser/rehearsal changes or +other contributors' unmerged presenter work is included. + +## Validation + +Candidate implementation; not yet qualified for release. The current-main reconciliation and +transport/font corrections have had source inspection and static diff checks only. No local Rust +builds, tests or formatter were run for this candidate; hosted compilation and regression tests +remain pending. Earlier-head validation recorded in the PR does not qualify this changed tree. +The original bug was reproduced on the deployed 0.7.0 browser build on 2026-10-14; that +deployment's exact commit is unknown. + +Regression coverage submitted for hosted execution checks successful image deduplication, +failed-send retries, session/reload refresh and clearing previews. The independent popup uses +the existing interface font definitions and UiFonts fallback discovery before laying out notes, +discarding font-install passes before input forwarding. Its font regression checks bundled +Arabic/Japanese/Hangul glyphs when those optional font inputs are present; missing optional +inputs do not establish script coverage. + +Source inspection verifies these lifecycle paths: a blocked popup offers an explicit retry +whose click handler calls the browser API; closing the popup offers reopening; ending the show +closes the popup; incoming messages check origin, source window and opening-session token. +This establishes the intended control flow, not empirical compatibility with each browser. + +Runtime checks that would add empirical coverage with a synthetic deck: + +- Popup allowed and blocked, explicit retry, and independent window positioning. +- Initial popup behavior in Safari and Firefox, including whether explicit retry is needed on + each show start. These browser families have not been exercised for this candidate; missing + manual coverage alone is not evidence of a defect or a mandatory merge blocker. +- Next/Previous, Home/End, numeric navigation, and no double advance on Space/Enter. +- Current/next previews, notes, pause/reset, black/white screens, All Slides and zoom. +- Navigation from both windows, including animation steps and the end screen. +- Close/reopen the presenter, reload it, end/restart the show, and close/reload the audience. +- Rejection of messages from the wrong origin, source window or prior session. +- Native presenter regressions, WASM compilation and the full repository quality gates. + +All runtime items above, including actual browser popup/reload/end-show execution, remain +unexecuted for this candidate. Shared native tests and source inspection do not constitute +browser-executed validation. Font subsets can legitimately omit individual script families; +the regression always checks installation/layout/discard handling, and checks a script's glyphs +only when its corresponding bundled face is present.