diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9d061bce..09b477f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
+ packages: write
concurrency:
group: release-${{ github.ref }}
diff --git a/Cargo.lock b/Cargo.lock
index 4c7e1520..408e9994 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
-version = "0.13.1"
+version = "0.14.1"
[[package]]
name = "deckcraft-opus"
-version = "0.13.1"
+version = "0.14.1"
[[package]]
name = "deckcraft-pdf"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.13.1"
+version = "0.14.1"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 83d5d06b..7cd456fd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
-version = "0.13.1"
+version = "0.14.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
diff --git a/README.md b/README.md
index 0cdfc584..730165a8 100644
--- a/README.md
+++ b/README.md
@@ -286,7 +286,15 @@ stand on its own.
|
| **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
| **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
| **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
-|
| **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+|
| **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
+|
| **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
+|
| **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
+|
| **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+|
| **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
+|
| **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
+|
| **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
+|
| **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
+|
| **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
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 ba5cd7d7..b34e963e 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -202,10 +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 {
- // An installed variable Ubuntu can expose many faces; use the bundled static
- // Light face so the damaged fixture always adds exactly one face.
- let bundled = FontDb::with_font_dirs(Vec::new());
- let mut bytes = bundled.face("Ubuntu", "Light").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/text/src/breaking.rs b/crates/text/src/breaking.rs
index 36b9e592..bb764b64 100644
--- a/crates/text/src/breaking.rs
+++ b/crates/text/src/breaking.rs
@@ -82,9 +82,18 @@ fn is_cj_nonstarter(c: char) -> bool {
)
}
+/// A character that continues a Latin-style word: a letter or digit that Unicode does not break
+/// between, as opposed to CJK ideographs, kana, spaces, punctuation and symbols such as `&` or `×`
+/// (which share the AL and AI line-break classes with letters).
+fn is_word_char(c: char) -> bool {
+ use unicode_linebreak::BreakClass::{Alphabetic, Ambiguous, HebrewLetter, Numeric};
+ matches!(unicode_linebreak::break_property(c as u32), Alphabetic | Ambiguous | HebrewLetter | Numeric) && c.is_alphanumeric()
+}
+
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
-/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
-/// Korean breaks only between words.
+/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/`, `\`, `?`, `!`, `}`
+/// or `+` inside a word (measured in PowerPoint 2016 for Windows; before an ideograph or a space
+/// the Unicode break stays), and Korean breaks only between words.
pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
@@ -113,7 +122,7 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec,
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) {
false
} else {
- latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
+ latin_break || !matches!(prev, '/' | '\\' | '?' | '!' | '}' | '+') || !is_word_char(next)
};
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
index f9f3f310..656dfdb6 100644
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -857,19 +857,37 @@ fn one_line(text: &str) -> f64 {
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
- // where it no longer fits; Unicode line breaking would allow a break after each slash.
- for sep in ["/", "\\"] {
+ // where it no longer fits; Unicode line breaking would allow a break after each slash. The
+ // same holds after ?, !, } and + (a URL's query, measured in PowerPoint 2016 for Windows).
+ for sep in ["/", "\\", "?", "!", "}", "+"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
+ // The line breaks where the next character no longer fits, not at an earlier separator
+ // (it may still end with one when that is where the width runs out).
+ let next: String = lines[0].chars().chain(lines[1].chars().take(1)).collect();
+ assert!(one_line(&next) > w, "no break right after {sep:?} while more fits: {lines:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
assert!(lines[0].ends_with('/'), "{lines:?}");
- // A slash followed by a space still breaks, and hyphens still break.
+ // Outside a Latin word the Unicode break stays: "日本!" fits, "語" moves to the next line.
+ let lines = wrapped("日本!語", one_line("日本!") + 0.5, |_| {});
+ assert_eq!(lines[0], "日本!", "{lines:?}");
+ // Nor before a symbol that only shares the letters' line-break class.
+ let (allowed, _) = break_opportunities("ab!&cd", false);
+ assert!(allowed[3], "break after ! before &: {allowed:?}");
+ // Latin text before CJK still breaks after the mark: "ab?" fits, "日本" moves down.
+ let lines = wrapped("ab?日本", one_line("ab?") + 0.5, |_| {});
+ assert_eq!(lines[0], "ab?", "{lines:?}");
+ // A separator followed by a space still breaks, and hyphens still break.
+ for sep in ["/", "?", "!"] {
+ let lines = wrapped(&format!("alpha{sep} beta"), one_line(&format!("alpha{sep} b")) + 0.5, |_| {});
+ assert_eq!(lines.iter().map(|l| l.trim_end()).collect::>(), [format!("alpha{sep}"), "beta".into()], "{lines:?}");
+ }
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
assert_eq!(lines[0], "alpha-beta-gamma-");
}
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/target-app-parity.md b/docs/target-app-parity.md
index 4f30ce5f..33b63188 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# DeckCraft vs Microsoft PowerPoint: parity assessment
-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (Animations shortfall: preview from the selected effect is in; previous: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (no break after `?`, `!`, `}`, `+` inside a word; previous: trivial, Animations shortfall: preview from the selected effect is in; before that: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
@@ -289,6 +289,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Animations shortfall: preview from the selected effect (Play From) is in. Percentages unchanged |
+| 2026-10-17 | trivial | A word no longer breaks after `?`, `!`, `}` or `+` (a URL's query wraps at the edge as in PowerPoint 2016 for Windows), as it already didn't after `/` and `\`. Headline percentages unchanged |
| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged |
| 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged |
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset |
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.
diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh
index 3a5508d7..e56692ad 100755
--- a/packaging/linux/flatpak-bundle.sh
+++ b/packaging/linux/flatpak-bundle.sh
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.deckcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
+OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
+ --oci) OCI=1; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
@@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
+if [[ "$OCI" = 1 ]]; then
+ OUT_OCI="$DIST/$BASENAME-oci"
+ rm -rf "$OUT_OCI"
+ flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
+ echo "wrote OCI layout to $OUT_OCI"
+fi
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh
new file mode 100755
index 00000000..a02f0606
--- /dev/null
+++ b/packaging/linux/flatpak-oci.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Build deckcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
+# for consumption by Flatpak OCI clients (flatpak-oci-specs).
+#
+# Usage:
+# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
+#
+# Environment variables:
+# REGISTRY Target registry image (default: ghcr.io/storytold/deckcraft)
+# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
+# GITHUB_TOKEN Token for GHCR authentication when --push is used
+set -euo pipefail
+# shellcheck source=../common.sh
+. "$(dirname "${BASH_SOURCE[0]}")/../common.sh"
+HERE="$ROOT/packaging/linux"
+
+PUSH=0
+TARBALL=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --push) PUSH=1; shift ;;
+ -h | --help) sed -n '2,12p' "$0"; exit 0 ;;
+ -*) echo "unknown argument: $1" >&2; exit 2 ;;
+ *) TARBALL="$1"; shift ;;
+ esac
+done
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64) ;;
+ aarch64 | arm64) ARCH=aarch64 ;;
+ *) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
+esac
+
+BASENAME="deckcraft-$VERSION-linux-$ARCH"
+OUT_OCI="$DIST/$BASENAME-oci"
+
+echo "==> Building Flatpak OCI layout for $BASENAME"
+"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
+
+[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
+
+REGISTRY="${REGISTRY:-ghcr.io/storytold/deckcraft}"
+TAG="${TAG:-$VERSION-$ARCH}"
+
+echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
+
+if [[ "$PUSH" = 1 ]]; then
+ command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
+
+ AUTH_ARGS=()
+ if [[ -n "${GITHUB_TOKEN:-}" ]]; then
+ AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
+ fi
+
+ echo "==> Pushing to $REGISTRY:$TAG"
+ skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
+
+ # Also update architecture-specific stable tag
+ echo "==> Pushing to $REGISTRY:stable-$ARCH"
+ skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
+
+ echo "==> Push complete"
+fi