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..da9dc7d8e 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -16,8 +16,10 @@ path = "src/main.rs" [dependencies] deckcraft-engine = { workspace = true } deckcraft-ui-egui = { workspace = true } +# 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 = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", 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..5a74db92d --- /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 skips it if it has not started it, 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 if it has not started it (one it has taken still finishes). +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