diff --git a/Cargo.lock b/Cargo.lock index 0e5395268c..91c94ce265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,10 +1158,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", @@ -7098,6 +7109,7 @@ dependencies = [ name = "wordcraft-web" version = "0.15.1" dependencies = [ + "craft-web-api", "craft-web-host", "eframe", "egui", diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index 971daf0cbd..8c42c988b1 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -17,7 +17,9 @@ path = "src/main.rs" wordcraft-engine = { workspace = true } wordcraft-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.wordcraft 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/wordcraft-web/src/api.rs b/apps/wordcraft-web/src/api.rs new file mode 100644 index 0000000000..e0dbfed200 --- /dev/null +++ b/apps/wordcraft-web/src/api.rs @@ -0,0 +1,61 @@ +//! `window.wordcraft`: 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 WordCraft's part: a [`Request`] that +//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the +//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs +//! when the app reaches it, as it does for a desktop client that timed out. + +use std::rc::Rc; +use std::sync::mpsc::{Receiver, TryRecvError, channel}; + +use craft_web_api::methods::Methods; +use craft_web_api::page::{PageApi, Request}; +use serde_json::{Value, json}; +use wasm_bindgen_futures::JsFuture; +use wordcraft_ui_egui::ControlRequest; + +/// The app id in `window.`. +const APP: &str = "wordcraft"; + +/// How often a waiting call looks for the app's reply, in milliseconds (about a frame). +const POLL_MS: i32 = 16; + +/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to +/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has. +pub fn install(ctx: egui::Context) -> Receiver { + let (queue, requests) = channel::(); + let request: Request = Rc::new(move |method, params| { + let (control, reply) = ControlRequest::new(method, params); + if queue.send(control).is_err() { + return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) }); + } + ctx.request_repaint(); + Box::pin(wait(reply)) + }); + if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() { + log::warn!("couldn't install window.{APP}: {e:?}"); + } + requests +} + +/// The app's reply, polled about once a frame. +async fn wait(reply: Receiver) -> Value { + loop { + match reply.try_recv() { + Ok(reply) => return reply, + Err(TryRecvError::Empty) => sleep(POLL_MS).await, + Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}), + } + } +} + +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; +} diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs index eb24ece139..285ce2e435 100644 --- a/apps/wordcraft-web/src/main.rs +++ b/apps/wordcraft-web/src/main.rs @@ -20,6 +20,8 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")] diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index a6bb8a8990..fb808decf8 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -76,7 +76,8 @@ pub fn start() { if let Some(bridge) = &app_bridge { bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone()); } - let mut app = WordApp::new(session, services); + let control = crate::api::install(cc.egui_ctx.clone()); + let mut app = WordApp::new(session, services).with_control(control); // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349). app.services.pasted = Some(pasted); app.autosave = false; diff --git a/packaging/web/README.md b/packaging/web/README.md index 07b3ba4ff9..5a56fdb184 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -159,6 +159,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.** `wordcraft:ready`, `wordcraft:open`, `wordcraft:save`, `wordcraft:dirty` and `wordcraft: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.wordcraft`) + +Every WordCraft web build puts `window.wordcraft` 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. `wordcraft.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. WordCraft's control requests cannot be withdrawn, so a call that times out in the page still runs when the app reaches it, as it does for a desktop client. + +```js +const doc = await wordcraft.docInspect(); // a summary of the open document +await wordcraft.execute("text.insert", { text: "Hello" }); +const { data } = await wordcraft.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 WordCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to