diff --git a/Cargo.lock b/Cargo.lock index 21cf7f054..a62aa7337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,9 +828,11 @@ dependencies = [ "cadcraft-fonts", "cadcraft-io", "cadcraft-ui-egui", + "craft-web-api", "craft-web-host", "eframe", "egui", + "js-sys", "log", "serde_json", "wasm-bindgen", @@ -1232,10 +1234,21 @@ dependencies = [ "zip", ] +[[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", diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index b1225faf7..b73444064 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -20,7 +20,9 @@ cadcraft-ui-egui = { workspace = true } cadcraft-io = { workspace = true } cadcraft-fonts = { 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.cadcraft 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 } @@ -33,4 +35,5 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] } 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", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] } diff --git a/apps/cadcraft-web/src/api.rs b/apps/cadcraft-web/src/api.rs new file mode 100644 index 000000000..574130f7e --- /dev/null +++ b/apps/cadcraft-web/src/api.rs @@ -0,0 +1,61 @@ +//! `window.cadcraft`: 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 CADCraft'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 drops its [`PendingReply`], which cancels a request the app has +//! not started, so the app skips it instead of running it late. + +use std::rc::Rc; +use std::sync::mpsc::{Receiver, channel}; + +use cadcraft_ui_egui::{ControlRequest, PendingReply}; +use craft_web_api::methods::Methods; +use craft_web_api::page::{PageApi, Request}; +use serde_json::{Value, json}; +use wasm_bindgen_futures::JsFuture; + +/// The app id in `window.`. +const APP: &str = "cadcraft"; + +/// How often a waiting call looks for the app's reply, in milliseconds (about a frame). +const POLL_MS: i32 = 16; + +/// Put `window.cadcraft` on the page and return the queue the app drains each frame (hand it to +/// `CadApp::with_control`). An existing `window.cadcraft` 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. Dropping this future (the page's timeout) drops +/// `reply`, which cancels the request if the app has not started it. +async fn wait(reply: PendingReply) -> Value { + loop { + if let Some(reply) = reply.try_reply() { + return reply; + } + sleep(POLL_MS).await; + } +} + +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/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 7a867b78a..3d1c00d4c 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -10,6 +10,8 @@ //! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] pub mod download; #[cfg(target_arch = "wasm32")] @@ -160,7 +162,8 @@ mod web { Box::new(move |cc| { // Embedded, the host sends the drawing: start on the Start tab, not Drawing1. let session = if app_bridge.is_some() { Session::empty() } else { Session::new() }; - let mut app = CadApp::new(session, services()); + let control = crate::api::install(cc.egui_ctx.clone()); + let mut app = CadApp::new(session, services()).with_control(control); app.system_languages = web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default(); if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6ed1d22b5..55e2da3f6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -25,7 +25,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::mpsc::{Receiver, Sender}; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use std::time::Duration; use cadcraft_engine::Input; @@ -87,6 +87,25 @@ impl PendingReply { // Started: a reply that arrived just now still counts. self.rx.try_recv().unwrap_or_else(|_| json!({"ok": false, "error": "timeout", "state": "may-have-run"})) } + + /// The reply if the app has answered, without waiting: a transport that cannot block (the web + /// shell) polls this once a frame. A request the app dropped without answering reports that. + pub fn try_reply(&self) -> Option { + match self.rx.try_recv() { + Ok(reply) => Some(reply), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app dropped the request"})), + } + } +} + +impl Drop for PendingReply { + /// A reply nobody waits for any more cancels a request the app has not started, as a timed-out + /// [`PendingReply::wait`] does: the client was told it failed, so the app must not run it late. + /// A request the app already started, or answered, is unaffected. + fn drop(&mut self) { + let _ = self.state.compare_exchange(QUEUED, CANCELLED, Ordering::AcqRel, Ordering::Acquire); + } } pub enum Outcome { @@ -728,6 +747,10 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image", "request": token}); }; + // No file system to write to (the web build): the PNG comes back inline, as `ui.render`'s does. + if cfg!(target_arch = "wasm32") { + return inline_screenshot(&img, token); + } let saved = match path { Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other), None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()), @@ -738,10 +761,60 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) } } +/// A screenshot as an inline PNG reply (`pngBase64`, `width`, `height`, `bytes`, like `ui.render`), for a +/// build that has no file system to write it to: the web build, where `std::env::temp_dir` panics. +fn inline_screenshot(img: &image::RgbaImage, token: u64) -> Value { + let (w, h) = img.dimensions(); + let mut png = Vec::new(); + if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) { + return json!({"ok": false, "error": e.to_string(), "request": token}); + } + json!({"ok": true, "request": token, "result": {"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "bytes": png.len()}}) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn an_inline_screenshot_carries_the_png_and_its_size() { + let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255])); + let reply = inline_screenshot(&img, 7); + assert_eq!(reply["ok"], true); + assert_eq!(reply["request"], 7); + assert_eq!((reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()), (Some(3), Some(2))); + let b64 = reply["result"]["pngBase64"].as_str().unwrap_or_default(); + assert!(b64.starts_with("iVBORw0KGgo"), "a PNG signature in base64: {b64:.20}"); + assert!(reply["result"]["bytes"].as_u64().is_some_and(|n| n > 30), "the PNG byte count is reported"); + } + + #[test] + fn dropping_a_pending_reply_cancels_a_request_the_app_has_not_started() { + let (request, pending) = ControlRequest::new("ui.inspect", json!({})); + drop(pending); + assert!(!request.begin(), "the app must skip a request nobody waits for"); + } + + #[test] + fn dropping_a_pending_reply_leaves_a_started_request_alone() { + let (request, pending) = ControlRequest::new("ui.inspect", json!({})); + assert!(request.begin()); + drop(pending); + assert_eq!(request.state.load(Ordering::Acquire), STARTED, "a started request is not cancelled"); + } + + #[test] + fn try_reply_polls_without_blocking() { + let (request, pending) = ControlRequest::new("ui.inspect", json!({})); + assert_eq!(pending.try_reply(), None, "no answer yet"); + assert!(request.begin()); + request.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default(); + assert_eq!(pending.try_reply(), Some(json!({"ok": true, "result": 1}))); + let (request, pending) = ControlRequest::new("ui.inspect", json!({})); + drop(request); + assert_eq!(pending.try_reply().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away"); + } + #[test] fn render_view_fit_finds_offscreen_drawing() { let far = cadcraft_geom::Bounds2::new(Vec2::new(9000.0, 9000.0), Vec2::new(9100.0, 9050.0)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 326bc4fe4..b620230bc 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,7 +60,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). | | `ui.resize` | `{width, height}` | Resize the window. | | `ui.focus` | | Activate the native window. Does not assign focus to a widget. | -| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. | +| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. In the web build there is no file system: `path` is ignored and the PNG comes back inline (`pngBase64`, `width`, `height`, `bytes`). | | `ui.render` | `{path?, width?, height?, fit?, window?, background?, plot_style?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view; `window` `[[x0, y0], [x1, y1]]` shows just that area (drawing units). `background`: `"dark"` (the canvas; the default without a plot style), `"white"` (the default with one, as a plot prints on paper), `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, built-in (`monochrome.ctb` prints every colour black, `grayscale.ctb` grey; `default.ctb`, `default.stb`, `monochrome.stb`) or one the drawing holds; `"none"` (or `""`) draws every object with its own properties, also in a layout whose page setup shows plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown `background` or `plot_style` is refused. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | | `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. | diff --git a/packaging/web/README.md b/packaging/web/README.md index 90b7d54f9..6a1c2c959 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 app's open size limit and a larger drawing is refused, and a name is cut to 255 characters. - **The event and message names are the interface.** `cadcraft:ready`, `cadcraft:open`, `cadcraft:save`, `cadcraft:dirty` and `cadcraft: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.cadcraft`) + +Every CADCraft web build puts `window.cadcraft` 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. `cadcraft.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 if it had not started it. + +```js +const drawing = await cadcraft.docInspect(); // counts, extents, layers, styles, selection +await cadcraft.execute("ui.theme", { theme: "dark" }); +const { data } = await cadcraft.screenshot(); // the canvas as PNG (base64) +``` + +The API is there in every mode, with or without a host. Opening a drawing, 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 CADCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to