From c7919102accca9e4106035502d1034b19a5db08f Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:45:26 +0800 Subject: [PATCH 1/2] deckcraft-web: window.deckcraft, the page command API from craft-web-api The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages. --- Cargo.lock | 14 ++- apps/deckcraft-web/Cargo.toml | 4 +- apps/deckcraft-web/src/api.rs | 151 +++++++++++++++++++++++++++++++++ apps/deckcraft-web/src/main.rs | 2 + apps/deckcraft-web/src/web.rs | 1 + packaging/web/README.md | 12 +++ 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 apps/deckcraft-web/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index b4059e689..5ff27fdd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,10 +935,21 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" dependencies = [ "wasm-bindgen", "web-sys", @@ -1331,6 +1342,7 @@ dependencies = [ name = "deckcraft-web" version = "0.15.0" dependencies = [ + "craft-web-api", "craft-web-host", "deckcraft-engine", "deckcraft-ui-egui", diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 58e8657a4..fc3c4f2af 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -17,7 +17,9 @@ path = "src/main.rs" deckcraft-engine = { workspace = true } deckcraft-ui-egui = { workspace = true } # The host messages (storytold/craft-libs): one contract and one bridge for the web builds. -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } +# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/apps/deckcraft-web/src/api.rs b/apps/deckcraft-web/src/api.rs new file mode 100644 index 000000000..a343636a6 --- /dev/null +++ b/apps/deckcraft-web/src/api.rs @@ -0,0 +1,151 @@ +//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the +//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`). +//! +//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, +//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that +//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the +//! reply. A call the page gave up on is released ([`Pending`]), so the app drops it unrun instead of +//! applying a command the page was told had failed. + +use std::sync::Arc; +use std::sync::atomic::AtomicU8; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use deckcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; + +/// How often a waiting call looks for the app's reply, in milliseconds (about a frame). +#[cfg(target_arch = "wasm32")] +const POLL_MS: i32 = 16; + +/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's +/// timeout fired, or the call was abandoned) releases the request: the app skips it unrun. +struct Pending { + reply: Receiver, + state: Arc, + answered: bool, +} + +impl Pending { + /// `Some` once the app has answered or gone away; `None` while the request is still waiting. + fn poll(&mut self) -> Option { + match self.reply.try_recv() { + Ok(reply) => { + self.answered = true; + Some(reply) + } + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => { + self.answered = true; + Some(json!({"ok": false, "error": "the app dropped the request"})) + } + } + } +} + +impl Drop for Pending { + fn drop(&mut self) { + if !self.answered { + ControlRequest::abandon(&self.state); + } + } +} + +#[cfg(target_arch = "wasm32")] +pub use web::install; + +#[cfg(target_arch = "wasm32")] +mod web { + use std::rc::Rc; + use std::sync::mpsc::channel; + + use craft_web_api::methods::Methods; + use craft_web_api::page::{PageApi, Request}; + use deckcraft_ui_egui::{ControlRequest, SlideApp}; + use serde_json::json; + use wasm_bindgen_futures::JsFuture; + + use super::{POLL_MS, Pending}; + + /// The app id in `window.`. + const APP: &str = "deckcraft"; + + /// Give the app its control queue and put `window.deckcraft` on the page. An existing + /// `window.deckcraft` keeps the functions it already has. + pub fn install(app: &mut SlideApp, ctx: egui::Context) { + let (queue, requests) = channel::(); + app.control_rx = Some(requests); + let request: Request = Rc::new(move |method, params| { + let (control, reply) = ControlRequest::new(method, params); + let mut pending = Pending { reply, state: control.state.clone(), answered: false }; + if queue.send(control).is_err() { + pending.answered = true; + return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) }); + } + ctx.request_repaint(); + Box::pin(async move { + loop { + if let Some(reply) = pending.poll() { + return reply; + } + sleep(POLL_MS).await; + } + }) + }); + if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() { + log::warn!("couldn't install window.{APP}: {e:?}"); + } + } + + async fn sleep(ms: i32) { + let promise = js_sys::Promise::new(&mut |resolve, _| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms); + } + }); + let _ = JsFuture::from(promise).await; + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::*; + + /// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its + /// sender gave up. + const TAKEN: u8 = 1; + const ABANDONED: u8 = 2; + + fn pending() -> (Pending, ControlRequest) { + let (control, reply) = ControlRequest::new("document.inspect", json!({})); + (Pending { reply, state: control.state.clone(), answered: false }, control) + } + + #[test] + fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() { + let (waiting, control) = pending(); + drop(waiting); + assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED); + assert!(!control.take(), "the app must not run a request the page was told had failed"); + } + + #[test] + fn an_answered_call_is_not_released() { + let (mut waiting, control) = pending(); + assert!(control.take()); + control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default(); + assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1}))); + drop(waiting); + assert_eq!(control.state.load(Ordering::SeqCst), TAKEN); + } + + #[test] + fn a_request_the_app_never_runs_waits_until_it_is_dropped() { + let (mut waiting, control) = pending(); + assert_eq!(waiting.poll(), None); + drop(control); + assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away"); + } +} diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 8fe335eb8..6964162df 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -21,6 +21,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(any(target_arch = "wasm32", test))] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")] diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index 8930a66f4..1a6c45683 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -79,6 +79,7 @@ pub fn start() { { crate::host::Bridge::set_author(&mut app, &author); } + crate::api::install(&mut app, cc.egui_ctx.clone()); Ok(Box::new(WebShell { app, inbox, bridge: app_bridge })) }), ) diff --git a/packaging/web/README.md b/packaging/web/README.md index 7a726f3a2..7d1e6d6a0 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -136,6 +136,18 @@ beside it. The page guards leaving itself (DeckCraft adds no guard of its own), The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `deckcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. DeckCraft reads bytes, so a host sends `bytes`, not a `File`. +### Driving it from the page (`window.deckcraft`) + +Every DeckCraft web build puts `window.deckcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `deckcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects. + +```js +const deck = await deckcraft.docInspect(); // a summary of the open presentation +await deckcraft.execute("shape.insert", { preset: "roundRect", rect: [100, 100, 240, 120], text: "Hello" }); +const { data } = await deckcraft.screenshot(); // the canvas as PNG (base64) +``` + +The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above. + ## Renderer selection and fallback flags DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From 4b1e50a6b0faae3962acb483fdb032e546d1f236 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:28:04 +0800 Subject: [PATCH 2/2] deckcraft-web: put each Cargo.toml comment above its own dependency, and say a call the app already took still finishes after a timeout --- apps/deckcraft-web/Cargo.toml | 2 +- apps/deckcraft-web/src/api.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index fc3c4f2af..da9dc7d8e 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -16,9 +16,9 @@ path = "src/main.rs" [dependencies] deckcraft-engine = { workspace = true } deckcraft-ui-egui = { workspace = true } -# The host messages (storytold/craft-libs): one contract and one bridge for the web builds. # The page command API (storytold/craft-libs#413): window.deckcraft over the control channel. craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +# The host messages (storytold/craft-libs): one contract and one bridge for the web builds. craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } egui = { workspace = true } log = { workspace = true } diff --git a/apps/deckcraft-web/src/api.rs b/apps/deckcraft-web/src/api.rs index a343636a6..5a74db92d 100644 --- a/apps/deckcraft-web/src/api.rs +++ b/apps/deckcraft-web/src/api.rs @@ -4,7 +4,7 @@ //! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, //! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that //! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the -//! reply. A call the page gave up on is released ([`Pending`]), so the app drops it unrun instead of +//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of //! applying a command the page was told had failed. use std::sync::Arc; @@ -19,7 +19,7 @@ use serde_json::{Value, json}; const POLL_MS: i32 = 16; /// A queued control request whose reply has not arrived. Dropping it before the reply (the page's -/// timeout fired, or the call was abandoned) releases the request: the app skips it unrun. +/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes). struct Pending { reply: Receiver, state: Arc,