diff --git a/Cargo.lock b/Cargo.lock index dfdde30e7..a39620f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,10 +995,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", @@ -2195,6 +2206,7 @@ dependencies = [ name = "gridcraft-web" version = "0.16.0" dependencies = [ + "craft-web-api", "craft-web-host", "eframe", "egui", diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index e5c599bd8..09c6d7e2a 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -17,7 +17,9 @@ path = "src/main.rs" gridcraft-engine = { workspace = true } gridcraft-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.gridcraft 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/gridcraft-web/src/api.rs b/apps/gridcraft-web/src/api.rs new file mode 100644 index 000000000..74f7206f6 --- /dev/null +++ b/apps/gridcraft-web/src/api.rs @@ -0,0 +1,151 @@ +//! `window.gridcraft`: 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 GridCraft'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 gridcraft_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 gridcraft_ui_egui::{ControlRequest, SheetApp}; + use serde_json::json; + use wasm_bindgen_futures::JsFuture; + + use super::{POLL_MS, Pending}; + + /// The app id in `window.`. + const APP: &str = "gridcraft"; + + /// Give the app its control queue and put `window.gridcraft` on the page. An existing + /// `window.gridcraft` keeps the functions it already has. + pub fn install(app: &mut SheetApp, 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 (`gridcraft_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/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs index 2c701feb4..823a68e9b 100644 --- a/apps/gridcraft-web/src/main.rs +++ b/apps/gridcraft-web/src/main.rs @@ -13,11 +13,16 @@ //! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its //! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s //! on the canvas. `&author=` names the user for notes and comments in either host mode. +//! `window.gridcraft` (`craft-web-api`) is the page command API in every mode: `request`, +//! `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` over the control methods +//! (`api`). //! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host //! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size, //! editing options…) are kept in `localStorage` (`prefs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(any(target_arch = "wasm32", test))] diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 268560520..1108c4c11 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -99,6 +99,7 @@ pub fn start() { } bridge.post_ready(); } + crate::api::install(&mut app, cc.egui_ctx.clone()); Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs })) }), ) diff --git a/packaging/web/README.md b/packaging/web/README.md index 1f346cde0..833a64e9f 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -157,6 +157,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h - **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters. - **The event and message names are the interface.** `gridcraft:ready`, `gridcraft:open`, `gridcraft:save`, `gridcraft:dirty` and `gridcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change. +### Driving it from the page (`window.gridcraft`) + +Every GridCraft web build puts `window.gridcraft` 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. `gridcraft.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, and the app drops it unrun. + +```js +const book = await gridcraft.docInspect(); // sheets, used ranges, tables, charts, names, selection +await gridcraft.execute("cell.set", { cell: "A1", input: "Total" }); +const { data } = await gridcraft.screenshot(); // the canvas as PNG (base64) +``` + +The API is there in every mode, with or without a host. Opening a workbook, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above. + ## Renderer selection GridCraft's web build is the same egui app as the desktop one, compiled to wasm