mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 12:30:16 -06:00
Merge pull request #2887 from helenkwok/web/page-api
cadcraft-web: window.cadcraft, the page command API from craft-web-api
This commit is contained in:
Generated
+14
-1
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.<app>`.
|
||||
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<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
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;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<ControlResponse> {
|
||||
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));
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user