mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
Merge branch 'main' into fix/410-docking-handle-names
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
name: Web API (page command API)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['crates/web-api/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-api.yml']
|
||||
pull_request:
|
||||
paths: ['crates/web-api/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-api.yml']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
web-api:
|
||||
# One OS: the crate is platform-independent JSON handling plus wasm32; no platform code.
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- name: Install the matching WASM test runner
|
||||
shell: bash
|
||||
run: |
|
||||
runner_version=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); print(next(p["version"] for p in d["package"] if p["name"] == "wasm-bindgen"))')
|
||||
cargo install wasm-bindgen-cli --version "$runner_version" --locked
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -p craft-web-api --all-targets --locked -- -D warnings
|
||||
- run: cargo clippy -p craft-web-api --all-targets --locked --target wasm32-unknown-unknown -- -D warnings
|
||||
- run: cargo test -p craft-web-api --locked
|
||||
- run: cargo test -p craft-web-api --locked --target wasm32-unknown-unknown
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Shared web host
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: shared-web-host-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
timeout-minutes: 20
|
||||
name: Web host lint, tests, WASM tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-web-host
|
||||
- run: cargo fmt -p craft-web-host --check
|
||||
- run: cargo clippy -p craft-web-host --all-targets --locked -- -D warnings
|
||||
- run: cargo clippy -p craft-web-host --all-targets --locked --target wasm32-unknown-unknown -- -D warnings
|
||||
- run: cargo test -p craft-web-host --locked
|
||||
- name: Install the matching WASM test runner
|
||||
shell: bash
|
||||
run: |
|
||||
runner_version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p')
|
||||
cargo install wasm-bindgen-cli --version "$runner_version" --locked
|
||||
- run: cargo test -p craft-web-host --locked --target wasm32-unknown-unknown
|
||||
Generated
+20
-13
@@ -1192,6 +1192,26 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -7421,7 +7441,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7436,18 +7455,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
@@ -52,7 +52,9 @@ craft-text = { path = "crates/text" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
craft-update = { path = "crates/update" }
|
||||
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
|
||||
craft-web-api = { path = "crates/web-api" }
|
||||
craft-window = { path = "crates/window" }
|
||||
craft-web-host = { path = "crates/web-host" }
|
||||
egui = { version = "0.37", default-features = false }
|
||||
rayon = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -111,6 +111,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-vectorcraft-format`](crates/vectorcraft-format/README.md): the envelope of VectorCraft's
|
||||
`.vectorcraft` file: its members, versions, embedded images and the data after the JSON, with the
|
||||
document as a type parameter. The document and its migrations stay in VectorCraft.
|
||||
- [`craft-web-api`](crates/web-api/README.md): the page command API every Craft web build offers its page — one
|
||||
`window.<app>` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's
|
||||
JSON control channel.
|
||||
- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
and compatibility boundaries across the Craft suite.
|
||||
- [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small shared
|
||||
|
||||
@@ -47,6 +47,11 @@ plist = "1"
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
dunce = "1"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
# `deflate-flate2` with flate2's default miniz_oxide backend, not zip's `deflate` (zopfli and
|
||||
# zlib-rs): Cargo unifies flate2's features across an app, and zlib-rs would switch the whole
|
||||
# app's flate2 to it, where level 1 is a static-Huffman mode that can make output larger than
|
||||
# its input (Lightcraft's 16-bit ZIP TIFFs on Windows).
|
||||
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
|
||||
flate2 = "1"
|
||||
winsafe = { version = "0.0.29", features = ["advapi", "kernel", "shell", "version"] }
|
||||
verifysign = "1"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "craft-web-api"
|
||||
description = "The page command API for Craft web builds: one window.<app> shape over the app's JSON control channel."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Already in every web build's Cargo.lock through eframe.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,62 @@
|
||||
# craft-web-api
|
||||
|
||||
The page command API for every Craft web build: one `window.<app>` shape over the app's JSON
|
||||
control channel.
|
||||
|
||||
## The JS API
|
||||
|
||||
`PageApi::install` puts this on `window.<app>` (`"vectorcraft"` → `window.vectorcraft`), without
|
||||
replacing an existing `window.<app>`: missing functions are added, an app that already has its own
|
||||
keeps them. Each function returns a Promise that resolves with the method's `result` or rejects with
|
||||
an `Error` carrying `error`. Every call has a timeout (30 s by default, `PageApi::with_timeout`);
|
||||
when the app does not answer in time, the error says so.
|
||||
|
||||
Which functions are installed follows the app's `Methods` table: only the functions whose control
|
||||
method the table provides are installed; `request` is always there.
|
||||
|
||||
| Function | Call | Table entry | Default method |
|
||||
|---|---|---|---|
|
||||
| `request` | `request(method, params)` | — | — (as given) |
|
||||
| `execute` | `execute(command, params)` | `execute` | `engine.execute {command, params}` |
|
||||
| `commands` | `commands()` | `commands` | `engine.commands {}` |
|
||||
| `docInspect` | `docInspect(params)` | `doc_inspect` | `document.inspect` |
|
||||
| `uiInspect` | `uiInspect(params)` | `ui_inspect` | `ui.inspect` |
|
||||
| `screenshot` | `screenshot(params)` | `screenshot` | `ui.screenshot` (with `data: true`) |
|
||||
|
||||
`Methods::default()` is the desktop control-server method names; an app whose control channel
|
||||
names a method differently, or lacks one, says so in its table (`Methods::NONE` is the empty table).
|
||||
|
||||
## How an app wires it
|
||||
|
||||
The app hands the crate its control channel as a `Request`; the crate never knows the app's
|
||||
internals. Using VectorCraft's channel (the same one the desktop control server uses) as the example:
|
||||
|
||||
```rust
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
|
||||
// Send a ControlRequest to the app and await its reply object ({ok, result} / {ok: false, error}).
|
||||
let request: Request = Rc::new(move |method: String, params: serde_json::Value| {
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
control_tx.send(req);
|
||||
Box::pin(poll_reply(rx)) // resolves when the app answers the request
|
||||
});
|
||||
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), request));
|
||||
page.install()?; // window.vectorcraft
|
||||
```
|
||||
|
||||
## Beside the host messages
|
||||
|
||||
Opening a document, handing a saved or exported file to the host, the document name and the dirty
|
||||
flag stay with the host messages in [`craft-web-host`](../web-host/README.md) (`<app>:open`,
|
||||
`<app>:save`, `<app>:dirty`, …) and the app's own control methods. A page reaches any control
|
||||
method through `request(method, params)`.
|
||||
|
||||
## Tests
|
||||
|
||||
`src/methods.rs` carries the native unit tests (the default and empty tables). `tests/page.rs` runs
|
||||
under `wasm-bindgen-test` in Node with a canned control channel: install and keeping an app's own
|
||||
functions, the table deciding which functions exist, the method and params each function sends,
|
||||
error replies as rejections, and the timeout.
|
||||
@@ -0,0 +1,40 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! One page command API for every Craft web build.
|
||||
//!
|
||||
//! Craft web apps (the `apps/<app>-web` builds of VectorCraft, PhotoCraft, FilmCraft, …) accept
|
||||
//! JSON control requests `{method, params}` from their page and answer `{ok, result}` or
|
||||
//! `{ok: false, error}` — the same protocol as the desktop control server. Until now each app kept
|
||||
//! its own copy of the code that exposes this to the page as `window.<app>`, in a different shape.
|
||||
//! This crate is the one shared shape.
|
||||
//!
|
||||
//! - [`page`] (wasm32 only) installs `window.<app>` — `request(method, params)`,
|
||||
//! `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and
|
||||
//! `screenshot(params)`; each function other than `request` is installed only when the app's
|
||||
//! [`methods::Methods`] table provides its control method.
|
||||
//! - [`methods`] is the target-independent table naming the control method behind each function.
|
||||
//!
|
||||
//! The app hands the crate its request function (its control channel) and a [`methods::Methods`]
|
||||
//! table; the crate never knows the app's internals.
|
||||
//!
|
||||
//! # How an app wires it
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use std::rc::Rc;
|
||||
//! use craft_web_api::methods::Methods;
|
||||
//! use craft_web_api::page::{PageApi, Request};
|
||||
//!
|
||||
//! // The control channel: send a ControlRequest, await its reply object.
|
||||
//! let request: Request = Rc::new(move |method: String, params: serde_json::Value| {
|
||||
//! let (req, rx) = ControlRequest::new(method, params);
|
||||
//! control_tx.send(req); // the same channel the desktop control server uses
|
||||
//! Box::pin(receive_reply(rx)) // resolves with {ok, result} / {ok: false, error}
|
||||
//! });
|
||||
//! let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), request));
|
||||
//! page.install()?; // window.vectorcraft
|
||||
//! ```
|
||||
|
||||
pub mod methods;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod page;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! The method table: which control method backs each `window.<app>` function. Target-independent,
|
||||
//! so it is natively tested and usable from any build.
|
||||
|
||||
/// The method table one app hands the crate: the control method behind each `window.<app>`
|
||||
/// function. `None` means that function is not installed; `request` is always installed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Methods {
|
||||
/// Behind `commands()`.
|
||||
pub commands: Option<&'static str>,
|
||||
/// Behind `execute(command, params)`, sent as `{command, params}`.
|
||||
pub execute: Option<&'static str>,
|
||||
/// Behind `docInspect(params)`.
|
||||
pub doc_inspect: Option<&'static str>,
|
||||
/// Behind `uiInspect(params)`.
|
||||
pub ui_inspect: Option<&'static str>,
|
||||
/// Behind `screenshot(params)`, sent with `data: true` unless the caller set `data`.
|
||||
pub screenshot: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Methods {
|
||||
/// A table with no methods: only `request` is installed.
|
||||
pub const NONE: Self = Self { commands: None, execute: None, doc_inspect: None, ui_inspect: None, screenshot: None };
|
||||
}
|
||||
|
||||
impl Default for Methods {
|
||||
/// The desktop control-server method names every Craft web build shares.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
commands: Some("engine.commands"),
|
||||
execute: Some("engine.execute"),
|
||||
doc_inspect: Some("document.inspect"),
|
||||
ui_inspect: Some("ui.inspect"),
|
||||
screenshot: Some("ui.screenshot"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Methods;
|
||||
|
||||
#[test]
|
||||
fn default_is_the_desktop_control_methods() {
|
||||
let m = Methods::default();
|
||||
assert_eq!(m.commands, Some("engine.commands"));
|
||||
assert_eq!(m.execute, Some("engine.execute"));
|
||||
assert_eq!(m.doc_inspect, Some("document.inspect"));
|
||||
assert_eq!(m.ui_inspect, Some("ui.inspect"));
|
||||
assert_eq!(m.screenshot, Some("ui.screenshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_has_no_methods() {
|
||||
let none = Methods::NONE;
|
||||
assert!([none.commands, none.execute, none.doc_inspect, none.ui_inspect, none.screenshot].iter().all(Option::is_none));
|
||||
assert_ne!(none, Methods::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! `window.<app>`: the page's command API. wasm32 only. The app hands this crate its control channel
|
||||
//! as a [`Request`]; the crate never knows the app's internals.
|
||||
//!
|
||||
//! Every function goes through one path, [`PageApi::call`], with the page's timeout; replies
|
||||
//! resolve with the method's `result` or reject with an `Error` carrying `error`.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::{Closure, JsValue};
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
|
||||
use crate::methods::Methods;
|
||||
|
||||
/// One control request: method and params in, the protocol's reply object out (`{ok, result}` or
|
||||
/// `{ok: false, error}`). The app builds this from its control channel — send a `ControlRequest`,
|
||||
/// await its reply.
|
||||
pub type Request = Rc<dyn Fn(String, Value) -> Pin<Box<dyn Future<Output = Value>>>>;
|
||||
|
||||
/// The page surface of one web build: the `window.<app>` object.
|
||||
pub struct PageApi {
|
||||
app: String,
|
||||
/// The control method behind each function: the desktop control-server method names this app uses.
|
||||
methods: Methods,
|
||||
request: Request,
|
||||
/// Each control request gets a timeout: if the app does not answer within this many
|
||||
/// milliseconds, the error says so (a hidden tab answers when it is shown again).
|
||||
timeout_ms: f64,
|
||||
}
|
||||
|
||||
impl PageApi {
|
||||
/// `app` is the global name to install (`"vectorcraft"` → `window.vectorcraft`); `methods` is
|
||||
/// the control method behind each function ([`Methods::default`] is the desktop control-server
|
||||
/// method names).
|
||||
pub fn new(app: &str, methods: Methods, request: Request) -> Self {
|
||||
Self { app: app.to_string(), methods, request, timeout_ms: 30_000.0 }
|
||||
}
|
||||
|
||||
/// Set the timeout for every control request, in milliseconds (default 30 s).
|
||||
pub fn with_timeout(mut self, timeout_ms: u32) -> Self {
|
||||
self.timeout_ms = timeout_ms as f64;
|
||||
self
|
||||
}
|
||||
|
||||
/// Install `window.<app>` = `{request, execute, commands, docInspect, uiInspect, screenshot}` —
|
||||
/// each a Promise-returning function that resolves with the method's `result` or rejects with
|
||||
/// an `Error` carrying `error`. Only the functions whose control method the app's [`Methods`]
|
||||
/// table provides are installed; `request` is always there.
|
||||
///
|
||||
/// An existing `window.<app>` is never replaced: the missing functions are added to it, and an
|
||||
/// app that already has its own keeps them.
|
||||
pub fn install(self: Rc<Self>) -> Result<(), JsValue> {
|
||||
let g = global();
|
||||
let name = JsValue::from_str(&self.app);
|
||||
let existing = get_prop(&g, &self.app);
|
||||
let existed = existing.as_ref().is_some_and(|v| v.is_object());
|
||||
let target: js_sys::Object = match existing {
|
||||
Some(v) if v.is_object() => v.unchecked_into(),
|
||||
_ => js_sys::Object::new(),
|
||||
};
|
||||
let add = |name: &str, f: JsValue| -> Result<(), JsValue> {
|
||||
if js_sys::Reflect::has(&target, &JsValue::from_str(name)).unwrap_or(true) {
|
||||
return Ok(()); // an app that already has its own function keeps it
|
||||
}
|
||||
js_sys::Reflect::set(&target, &JsValue::from_str(name), &f).map(|_| ())
|
||||
};
|
||||
|
||||
let page = self.clone();
|
||||
add(
|
||||
"request",
|
||||
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |method: String, params: JsValue| {
|
||||
let page = page.clone();
|
||||
future_to_promise(async move { request_promise_outcome(&page.call(&method, from_js(¶ms)).await) })
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
|
||||
if let Some(method) = self.methods.execute {
|
||||
let page = self.clone();
|
||||
add(
|
||||
"execute",
|
||||
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |command: String, params: JsValue| {
|
||||
let page = page.clone();
|
||||
future_to_promise(async move {
|
||||
let params = json!({"command": command, "params": from_js(¶ms)});
|
||||
request_promise_outcome(&page.call(method, params).await)
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(method) = self.methods.commands {
|
||||
let page = self.clone();
|
||||
add(
|
||||
"commands",
|
||||
Closure::<dyn FnMut() -> js_sys::Promise>::new(move || {
|
||||
let page = page.clone();
|
||||
future_to_promise(async move { request_promise_outcome(&page.call(method, json!({})).await) })
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
}
|
||||
|
||||
for (name, method) in [("docInspect", self.methods.doc_inspect), ("uiInspect", self.methods.ui_inspect)] {
|
||||
if let Some(method) = method {
|
||||
let page = self.clone();
|
||||
add(
|
||||
name,
|
||||
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
|
||||
let page = page.clone();
|
||||
future_to_promise(async move { request_promise_outcome(&page.call(method, from_js(¶ms)).await) })
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(method) = self.methods.screenshot {
|
||||
let page = self.clone();
|
||||
add(
|
||||
"screenshot",
|
||||
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
|
||||
let page = page.clone();
|
||||
let params = screenshot_params(from_js(¶ms));
|
||||
future_to_promise(async move { request_promise_outcome(&page.call(method, params).await) })
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
}
|
||||
|
||||
if existed { Ok(()) } else { js_sys::Reflect::set(&g, &name, &target).map(|_| ()) }
|
||||
}
|
||||
|
||||
/// One control request: the single path through the app's channel that every function takes,
|
||||
/// with the page's timeout. If the app does not answer within the timeout, the error says so —
|
||||
/// a hidden tab answers when it is shown again.
|
||||
async fn call(&self, method: &str, params: Value) -> Value {
|
||||
let timeout = Timeout::new(self.timeout_ms);
|
||||
let request = self.request.clone();
|
||||
let method = method.to_string();
|
||||
let sent = method.clone();
|
||||
let reply = future_to_promise(async move {
|
||||
let reply = request(sent, params).await;
|
||||
Ok(to_js(&reply))
|
||||
});
|
||||
let raced = js_sys::Promise::race(&js_sys::Array::of2(&reply, &timeout.promise));
|
||||
let outcome = JsFuture::from(raced).await;
|
||||
drop(timeout); // clear the timer and free its callback once the race is over
|
||||
match outcome {
|
||||
Err(_) => json!({
|
||||
"ok": false,
|
||||
"error": format!(
|
||||
"{} did not answer {} within {} s (a hidden tab answers when it is shown again)",
|
||||
self.app, method, self.timeout_ms / 1000.0
|
||||
),
|
||||
}),
|
||||
Ok(value) => from_js(&value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `screenshot` params: `data: true` is added so the reply carries the PNG, unless the caller set
|
||||
/// `data` itself.
|
||||
fn screenshot_params(mut params: Value) -> Value {
|
||||
match params.as_object_mut() {
|
||||
Some(o) => {
|
||||
o.entry("data").or_insert(json!(true));
|
||||
params
|
||||
}
|
||||
None => json!({"data": true}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A promise that rejects `ms` milliseconds from now. Dropping it clears the timer (harmless once
|
||||
/// fired) and frees the timer's callback, so a call leaves nothing behind. The timer comes from
|
||||
/// `setTimeout` on the global object, reached through `Reflect` — no web-sys, so the crate keeps
|
||||
/// working in any JS realm.
|
||||
struct Timeout {
|
||||
promise: js_sys::Promise,
|
||||
id: f64,
|
||||
_fire: Option<Closure<dyn FnMut()>>,
|
||||
}
|
||||
|
||||
impl Timeout {
|
||||
fn new(ms: f64) -> Self {
|
||||
let mut fire = None;
|
||||
let mut id = f64::INFINITY;
|
||||
// `Promise::new` runs its executor before returning, so `fire` and `id` are set here.
|
||||
let promise = js_sys::Promise::new(&mut |_resolve: js_sys::Function, reject: js_sys::Function| {
|
||||
let callback = Closure::<dyn FnMut()>::new(move || {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::NULL);
|
||||
});
|
||||
id = set_timeout(callback.as_ref().clone(), ms);
|
||||
fire = Some(callback);
|
||||
});
|
||||
Self { promise, id, _fire: fire }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Timeout {
|
||||
fn drop(&mut self) {
|
||||
clear_timeout(self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `setTimeout` global, reached through the global object + `Reflect` (no web-sys). Returns the
|
||||
/// timer id; a missing `setTimeout` yields an id of infinity, which [`clear_timeout`] ignores.
|
||||
fn set_timeout(callback: JsValue, ms: f64) -> f64 {
|
||||
let g = global();
|
||||
get_prop(&g, "setTimeout")
|
||||
.filter(|v| v.is_function())
|
||||
.and_then(|f| {
|
||||
let f: js_sys::Function = f.unchecked_into();
|
||||
f.call2(&g, &callback, &JsValue::from_f64(ms)).ok()
|
||||
})
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(f64::INFINITY)
|
||||
}
|
||||
|
||||
/// Clear a `setTimeout` id, if the environment has `clearTimeout`.
|
||||
fn clear_timeout(id: f64) {
|
||||
let g = global();
|
||||
if let Some(f) = get_prop(&g, "clearTimeout").filter(|v| v.is_function()) {
|
||||
let f: js_sys::Function = f.unchecked_into();
|
||||
let _ = f.call1(&g, &JsValue::from_f64(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Promise` outcome of a `window.<app>` call: resolve with `result`, reject with an `Error`
|
||||
/// carrying `error`.
|
||||
fn request_promise_outcome(reply: &Value) -> Result<JsValue, JsValue> {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
|
||||
} else {
|
||||
Err(js_sys::Error::new(&error_text(reply)).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply's `error` as displayable text.
|
||||
fn error_text(reply: &Value) -> String {
|
||||
match reply.get("error") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(other) => serde_json::to_string(other).unwrap_or_else(|_| "error".to_string()),
|
||||
None => "error".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The global object (`window` in a browser, `globalThis` in Node): `window.<app>`, `setTimeout`
|
||||
/// and `clearTimeout` live on it.
|
||||
fn global() -> js_sys::Object {
|
||||
js_sys::global()
|
||||
}
|
||||
|
||||
fn get_prop(target: &js_sys::Object, name: &str) -> Option<JsValue> {
|
||||
js_sys::Reflect::get(target, &JsValue::from_str(name)).ok()
|
||||
}
|
||||
|
||||
/// JSON → JS. VectorCraft's `to_js`.
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
/// JS → JSON. VectorCraft's `from_js`; absent values become an empty object.
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
//! Node tests for the page part: a canned control channel that records what it is asked, and the
|
||||
//! `window.<app>` functions called the way a page calls them. No browser.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::{Closure, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wasm_bindgen_test::wasm_bindgen_test;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
|
||||
thread_local! {
|
||||
/// (method, params) pairs recorded by the fake control channel.
|
||||
static REQUESTS: RefCell<Vec<(String, Value)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn reset() {
|
||||
REQUESTS.with(|r| r.borrow_mut().clear());
|
||||
let _ = js_sys::Reflect::delete_property(&js_sys::global(), &"vectorcraft".into());
|
||||
}
|
||||
|
||||
/// A control channel that always answers `reply`.
|
||||
fn canned_request(reply: Value) -> Request {
|
||||
Rc::new(move |_method: String, _params: Value| {
|
||||
let reply = reply.clone();
|
||||
Box::pin(async move { reply })
|
||||
})
|
||||
}
|
||||
|
||||
/// A control channel that records every call and always answers `reply`.
|
||||
fn recording_request(reply: Value) -> Request {
|
||||
Rc::new(move |method: String, params: Value| {
|
||||
REQUESTS.with(|r| r.borrow_mut().push((method.clone(), params)));
|
||||
let reply = reply.clone();
|
||||
Box::pin(async move { reply })
|
||||
})
|
||||
}
|
||||
|
||||
fn to_value(v: &JsValue) -> Value {
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn api() -> js_sys::Object {
|
||||
js_sys::Reflect::get(&js_sys::global(), &"vectorcraft".into()).expect("window.vectorcraft").unchecked_into()
|
||||
}
|
||||
|
||||
fn has(api: &js_sys::Object, name: &str) -> bool {
|
||||
js_sys::Reflect::has(api, &JsValue::from_str(name)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Call `window.vectorcraft.<name>(args…)` and await its promise.
|
||||
async fn call(name: &str, args: &[JsValue]) -> Result<JsValue, JsValue> {
|
||||
let api = api();
|
||||
let f: js_sys::Function = js_sys::Reflect::get(&api, &JsValue::from_str(name)).expect(name).unchecked_into();
|
||||
let promise: js_sys::Promise = f.apply(&api, &args.iter().collect::<js_sys::Array>()).expect("call").unchecked_into();
|
||||
JsFuture::from(promise).await
|
||||
}
|
||||
|
||||
fn obj(json: &str) -> JsValue {
|
||||
js_sys::JSON::parse(json).expect("valid JSON")
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn install_keeps_existing_functions() {
|
||||
reset();
|
||||
let g = js_sys::global();
|
||||
let existing = js_sys::Object::new();
|
||||
let marker = Closure::<dyn Fn()>::new(|| {}).into_js_value();
|
||||
js_sys::Reflect::set(&existing, &"execute".into(), &marker).expect("set execute");
|
||||
js_sys::Reflect::set(&g, &"vectorcraft".into(), &existing.into()).expect("set window.vectorcraft");
|
||||
|
||||
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(json!({"ok": true, "result": {}}))));
|
||||
page.install().expect("install");
|
||||
|
||||
let api = api();
|
||||
let kept = js_sys::Reflect::get(&api, &"execute".into()).expect("execute");
|
||||
assert_eq!(kept, marker, "an app's own execute is kept");
|
||||
for name in ["request", "execute", "commands", "docInspect", "uiInspect", "screenshot"] {
|
||||
assert!(has(&api, name), "{name} must be added");
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn methods_table_decides_which_functions_exist() {
|
||||
reset();
|
||||
let only = Methods { commands: Some("c.commands"), ..Methods::NONE };
|
||||
Rc::new(PageApi::new("vectorcraft", only, canned_request(json!({"ok": true, "result": {}})))).install().expect("install");
|
||||
let api = api();
|
||||
assert!(has(&api, "request"), "request is always installed");
|
||||
assert!(has(&api, "commands"));
|
||||
for name in ["execute", "docInspect", "uiInspect", "screenshot"] {
|
||||
assert!(!has(&api, name), "{name} has no method and must not be installed");
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn functions_send_their_methods() {
|
||||
reset();
|
||||
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), recording_request(json!({"ok": true, "result": {"done": 1}}))));
|
||||
page.install().expect("install");
|
||||
|
||||
let out = call("execute", &["object.rectangle".into(), obj(r#"{"width": 100}"#)]).await.expect("execute resolves");
|
||||
assert_eq!(to_value(&out), json!({"done": 1}), "a success resolves with the result");
|
||||
call("commands", &[]).await.expect("commands");
|
||||
call("docInspect", &[obj(r#"{"depth": 2}"#)]).await.expect("docInspect");
|
||||
call("uiInspect", &[]).await.expect("uiInspect");
|
||||
call("screenshot", &[]).await.expect("screenshot");
|
||||
call("screenshot", &[obj(r#"{"data": false, "scale": 2}"#)]).await.expect("screenshot with data");
|
||||
call("request", &["custom.method".into(), obj(r#"{"x": 1}"#)]).await.expect("request");
|
||||
|
||||
let calls = REQUESTS.with(|r| r.borrow().clone());
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
("engine.execute".to_string(), json!({"command": "object.rectangle", "params": {"width": 100}})),
|
||||
("engine.commands".to_string(), json!({})),
|
||||
("document.inspect".to_string(), json!({"depth": 2})),
|
||||
("ui.inspect".to_string(), json!({})),
|
||||
("ui.screenshot".to_string(), json!({"data": true})),
|
||||
("ui.screenshot".to_string(), json!({"data": false, "scale": 2})),
|
||||
("custom.method".to_string(), json!({"x": 1})),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn error_replies_reject_with_the_error_text() {
|
||||
reset();
|
||||
Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(json!({"ok": false, "error": "boom"})))).install().expect("install");
|
||||
let err = match call("commands", &[]).await {
|
||||
Err(err) => err,
|
||||
Ok(_) => panic!("an error reply must reject"),
|
||||
};
|
||||
assert_eq!(String::from(err.unchecked_into::<js_sys::Error>().message()), "boom");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn timeout_gives_an_error_saying_so() {
|
||||
reset();
|
||||
// A control channel that never answers: it waits on a promise that never settles. (Looping on
|
||||
// resolved promises instead would starve the timer queue, so the page's timeout could never fire.)
|
||||
let pending: Request = Rc::new(|_method: String, _params: Value| {
|
||||
Box::pin(async {
|
||||
let _ = JsFuture::from(js_sys::Promise::new(&mut |_, _| {})).await;
|
||||
Value::Null
|
||||
})
|
||||
});
|
||||
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), pending).with_timeout(10));
|
||||
page.install().expect("install");
|
||||
let err = match call("execute", &["object.rectangle".into(), js_sys::Object::new().into()]).await {
|
||||
Err(err) => err,
|
||||
Ok(_) => panic!("a never-answering app must time out"),
|
||||
};
|
||||
let message = String::from(err.unchecked_into::<js_sys::Error>().message());
|
||||
assert!(message.contains("vectorcraft did not answer engine.execute within 0.01 s"), "{message}");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "craft-web-host"
|
||||
description = "The host messages between a Craft app's web build and the page that hosts it: one contract and one bridge for every app."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# Already in every web build's Cargo.lock through eframe.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
web-sys = { version = "0.3", features = ["Blob", "CustomEvent", "CustomEventInit", "Element", "Event", "EventTarget", "Location", "MessageEvent", "Window"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,51 @@
|
||||
# craft-web-host
|
||||
|
||||
The host messages between a Craft app's web build and the page that hosts it, as one contract and one bridge for every app (storytold/craft-libs#358). A page frames the app with `?host=parent`, or runs it directly with `data-host="page"` on its canvas; either way the same messages pass files in, saves out, and unsaved-changes state, so a page can host any of the apps the same way.
|
||||
|
||||
The crate has two parts:
|
||||
|
||||
- **The contract** (all targets): the message kinds and their `<app>:<kind>` types, the contract version, how the hosting mode is read from the URL and the canvas, and the clean-up of file names a host passes in. Pure, tested natively.
|
||||
- **The bridge** (`web`, wasm32 only): `Host::connect(app, canvas)` finds the host, `listen` hears its messages, and `post_ready`, `post_save`, `post_dirty`, `post_failed` and `post_handoff` answer it. Only the same-origin parent window is heard and answered; with a parent on another origin the app runs as if unframed.
|
||||
|
||||
## Messages
|
||||
|
||||
| Kind | Direction | Fields | Meaning |
|
||||
|---|---|---|---|
|
||||
| `ready` | app → host | `version`, `appVersion?`, `file?` | the app can take a document; `file: true` means it reads a `File`/`Blob` in slices |
|
||||
| `host` | host → app | `version`, `handoff?` | what the host supports; `handoff: true` means it routes hand-offs |
|
||||
| `open` | host → app | `name`, and `bytes` or `file` + `size` | open a document as File › Open would |
|
||||
| `save` | app → host | `name`, `bytes`, `id?`, `saveAs?` | Save, Save As or Export: the host stores the file |
|
||||
| `saved` | host → app | `id` | the save with that `id` is stored; the app clears dirty |
|
||||
| `dirty` | app → host | `dirty` | unsaved changes appeared or went away |
|
||||
| `failed` | app → host | `error`, `id?` | a document or a save could not be taken |
|
||||
| `handoff` | app → host | `name`, and `bytes` or `file` + `size` | this file belongs to another app |
|
||||
| `handoff-done` | host → app | `name`, `app` | the host gave it to the app that edits it |
|
||||
|
||||
With `?host=parent` a message is an object with `type` (`pdfcraft:open`) and the fields, sent with same-origin `postMessage`; `bytes` go as a transferred `ArrayBuffer`. With `data-host="page"` it is a `CustomEvent` of that type on the canvas, with the fields in `detail`.
|
||||
|
||||
Compatibility with the bridges shipped before this crate:
|
||||
|
||||
- Fields marked `?` are optional. A host answers `saved` only for a `save` that carries an `id`; an app that sends none treats its save as done once it is posted.
|
||||
- A host that never sends `host` gets the behaviour of the earlier bridges.
|
||||
- `size` beside `file` is for hosts and older bridges; this crate takes a `Blob`'s length from the `Blob` itself.
|
||||
- A page host may dispatch an event while the app's handler runs (a `saved` answering the `save` it just posted). The bridge holds it and calls the handler again once the running call returns, so messages arrive one at a time and in order.
|
||||
- `version` is `"1"`. The earlier bridges put the app's own release in it (`"0.14.0"`), so a host treats a `version` it doesn't know as `"1"`. The crate sends the release as `appVersion`.
|
||||
|
||||
## Use in an app's web shell
|
||||
|
||||
```rust
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(host) = craft_web_host::web::Host::connect("pdfcraft", &canvas) {
|
||||
host.listen(move |message| match message {
|
||||
craft_web_host::web::Message::Open { name, data } => {
|
||||
// Check data.size() against the app's limit before copying it in.
|
||||
}
|
||||
craft_web_host::web::Message::Saved { id } => { /* clear dirty for that save */ }
|
||||
_ => {}
|
||||
});
|
||||
let _ = host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
// Saves: host.post_save(name, &bytes, None, save_as); unsaved state: host.post_dirty(dirty).
|
||||
}
|
||||
```
|
||||
|
||||
The page command API is [`craft-web-api`](../web-api/README.md) (storytold/craft-libs#413): `window.<app>.request` and the command functions. It sits beside these messages. Opening, saving, the document name and the dirty flag stay here.
|
||||
@@ -0,0 +1,298 @@
|
||||
//! The host messages between a Craft app's web build and the page that hosts it: one contract for
|
||||
//! the whole family (storytold/craft-libs#358), so each app's bridge is a few calls instead of its
|
||||
//! own copy.
|
||||
//!
|
||||
//! Messages are plain objects with a `type` of `<app>:<kind>` (`cadcraft:open`):
|
||||
//!
|
||||
//! | Kind | Direction | Fields |
|
||||
//! |---|---|---|
|
||||
//! | `ready` | app → host | `version`, `appVersion?`, `file?` (`true`: it reads a `File`/`Blob` in slices) |
|
||||
//! | `host` | host → app | `version`, `handoff?` (`true`: the host routes hand-offs) |
|
||||
//! | `open` | host → app | `name`, and `bytes` or `file` + `size` |
|
||||
//! | `save` | app → host | `name`, `bytes`, `id?`, `saveAs?` |
|
||||
//! | `saved` | host → app | `id` |
|
||||
//! | `dirty` | app → host | `dirty` |
|
||||
//! | `failed` | app → host | `error`, `id?` |
|
||||
//! | `handoff` | app → host | `name`, and `bytes` or `file` + `size` |
|
||||
//! | `handoff-done` | host → app | `name`, `app` |
|
||||
//!
|
||||
//! A host answers `saved` only for a `save` that carries an `id`; an app that sends none treats a
|
||||
//! save as done once it is posted. Fields marked `?` are optional, so the bridges shipped before
|
||||
//! this crate already speak version 1. Those bridges put the app's own release in `ready`'s
|
||||
//! `version` (`"0.14.0"`): a host treats any `version` it doesn't know as `"1"`.
|
||||
//!
|
||||
//! The app is hosted when its URL has `?host=parent` (framed by a same-origin page: messages go
|
||||
//! through `postMessage`) or its canvas has `data-host="page"` (the page runs it directly:
|
||||
//! messages are `CustomEvent`s on the canvas, the fields in `detail`). This module is the contract
|
||||
//! itself and builds everywhere; [`web`] (wasm32 only) is the bridge an app's web shell calls.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web;
|
||||
|
||||
/// The contract version an app reports in `ready` and a host in `host`. It changes only when a
|
||||
/// field changes meaning; new optional fields keep it.
|
||||
pub const VERSION: &str = "1";
|
||||
|
||||
/// Longest file name taken from a host or put in a message, in characters.
|
||||
pub const MAX_NAME_CHARS: usize = 255;
|
||||
|
||||
/// How a page hosts the app.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// `?host=parent`: a same-origin page frames the app; messages go through `postMessage`.
|
||||
Parent,
|
||||
/// `data-host="page"` on the app's canvas: the page runs the app itself; messages are
|
||||
/// `CustomEvent`s on the canvas.
|
||||
Page,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The mode for a page URL's query (with or without its leading `?`) and the canvas's
|
||||
/// `data-host` attribute. `?host=parent` wins; anything else is not hosted.
|
||||
pub fn detect(query: &str, data_host: Option<&str>) -> Option<Self> {
|
||||
let query = query.strip_prefix('?').unwrap_or(query);
|
||||
if query.split('&').any(|pair| pair == "host=parent") {
|
||||
Some(Self::Parent)
|
||||
} else if data_host == Some("page") {
|
||||
Some(Self::Page)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an app posts to its host.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outgoing {
|
||||
Ready,
|
||||
Save,
|
||||
Dirty,
|
||||
Failed,
|
||||
Handoff,
|
||||
}
|
||||
|
||||
/// What a host sends to an app.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Incoming {
|
||||
Host,
|
||||
Open,
|
||||
Saved,
|
||||
HandoffDone,
|
||||
}
|
||||
|
||||
impl Outgoing {
|
||||
pub const fn kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ready => "ready",
|
||||
Self::Save => "save",
|
||||
Self::Dirty => "dirty",
|
||||
Self::Failed => "failed",
|
||||
Self::Handoff => "handoff",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Incoming {
|
||||
pub const ALL: [Self; 4] = [Self::Host, Self::Open, Self::Saved, Self::HandoffDone];
|
||||
|
||||
pub const fn kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::Host => "host",
|
||||
Self::Open => "open",
|
||||
Self::Saved => "saved",
|
||||
Self::HandoffDone => "handoff-done",
|
||||
}
|
||||
}
|
||||
|
||||
/// The incoming kind a message `type` names for `app`, or `None` for another app's message or
|
||||
/// one this contract doesn't send to apps.
|
||||
pub fn parse(app: &str, message_type: &str) -> Option<Self> {
|
||||
let kind = message_type.strip_prefix(app)?.strip_prefix(':')?;
|
||||
Self::ALL.into_iter().find(|k| k.kind() == kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `type` of a message: `<app>:<kind>`.
|
||||
pub fn message_type(app: &str, kind: &str) -> String {
|
||||
format!("{app}:{kind}")
|
||||
}
|
||||
|
||||
/// A file name from a host, made safe to show and save under: path separators and control
|
||||
/// characters dropped, surrounding space trimmed, at most [`MAX_NAME_CHARS`] characters, and
|
||||
/// `fallback` when nothing is left.
|
||||
pub fn file_name(raw: Option<&str>, fallback: &str) -> String {
|
||||
let base = raw.unwrap_or_default().rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
|
||||
return fallback.to_string();
|
||||
}
|
||||
trimmed.chars().take(MAX_NAME_CHARS).collect()
|
||||
}
|
||||
|
||||
/// Remember `next` before the send. A page host can, while that send is still
|
||||
/// running, report a different state; that later report must win. A failed send
|
||||
/// restores the previous state only when nothing else recorded one.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) fn note_dirty(dirty: &Cell<Option<bool>>, next: bool, post: impl FnOnce() -> bool) {
|
||||
if dirty.get() == Some(next) {
|
||||
return;
|
||||
}
|
||||
let previous = dirty.replace(Some(next));
|
||||
if !post() && dirty.get() == Some(next) {
|
||||
dirty.set(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the host's messages to the app's handler one at a time. A page host dispatches its events
|
||||
/// synchronously, so it can answer while the handler runs (a `saved` for the `save` the handler
|
||||
/// just posted): that message waits here and is handled as soon as the running call returns, never
|
||||
/// dropped and never given to a second, overlapping call.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) struct Inbox<M, F> {
|
||||
handler: RefCell<F>,
|
||||
queue: RefCell<VecDeque<M>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
impl<M, F: FnMut(M)> Inbox<M, F> {
|
||||
pub(crate) fn new(handler: F) -> Self {
|
||||
Self { handler: RefCell::new(handler), queue: RefCell::new(VecDeque::new()) }
|
||||
}
|
||||
|
||||
pub(crate) fn deliver(&self, message: M) {
|
||||
self.queue.borrow_mut().push_back(message);
|
||||
// Already running: the outer call drains the queue.
|
||||
let Ok(mut handler) = self.handler.try_borrow_mut() else { return };
|
||||
loop {
|
||||
let next = self.queue.borrow_mut().pop_front();
|
||||
let Some(message) = next else { break };
|
||||
handler(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_query_switch_wins_over_the_canvas_attribute() {
|
||||
assert_eq!(Mode::detect("?host=parent", None), Some(Mode::Parent));
|
||||
assert_eq!(Mode::detect("file=a.pdf&host=parent", Some("page")), Some(Mode::Parent));
|
||||
assert_eq!(Mode::detect("", Some("page")), Some(Mode::Page));
|
||||
assert_eq!(Mode::detect("?host=parents", None), None);
|
||||
assert_eq!(Mode::detect("?xhost=parent", None), None);
|
||||
assert_eq!(Mode::detect("", Some("frame")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_this_apps_incoming_kinds_parse() {
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:open"), Some(Incoming::Open));
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:handoff-done"), Some(Incoming::HandoffDone));
|
||||
assert_eq!(Incoming::parse("pdfcraft", "cadcraft:open"), None, "another app's message");
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:save"), None, "app-to-host kinds are not incoming");
|
||||
assert_eq!(Incoming::parse("pdf", "pdfcraft:open"), None, "an app id that is only a prefix");
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraftopen"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_round_trips_through_its_type() {
|
||||
for kind in Incoming::ALL {
|
||||
assert_eq!(Incoming::parse("gridcraft", &message_type("gridcraft", kind.kind())), Some(kind));
|
||||
}
|
||||
assert_eq!(message_type("cadcraft", Outgoing::Handoff.kind()), "cadcraft:handoff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_file_names_are_cleaned() {
|
||||
assert_eq!(file_name(Some("plan.dxf"), "Drawing.dxf"), "plan.dxf");
|
||||
assert_eq!(file_name(Some(" ../../etc/passwd "), "x"), "passwd");
|
||||
assert_eq!(file_name(Some("C:\\Users\\sam\\notes.pdf"), "x"), "notes.pdf");
|
||||
assert_eq!(file_name(Some("a\u{0}b\nc.txt"), "x"), "abc.txt");
|
||||
assert_eq!(file_name(Some("dir/"), "Drawing.dxf"), "Drawing.dxf");
|
||||
assert_eq!(file_name(Some(".."), "x"), "x");
|
||||
assert_eq!(file_name(None, "Document.pdf"), "Document.pdf");
|
||||
assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dirty_report_during_the_send_wins_and_a_failed_send_is_forgotten() {
|
||||
let dirty = Cell::new(Some(false));
|
||||
note_dirty(&dirty, true, || {
|
||||
note_dirty(&dirty, false, || true);
|
||||
true
|
||||
});
|
||||
assert_eq!(dirty.get(), Some(false));
|
||||
|
||||
let dirty = Cell::new(None);
|
||||
note_dirty(&dirty, true, || false);
|
||||
assert_eq!(dirty.get(), None);
|
||||
|
||||
let dirty = Cell::new(Some(true));
|
||||
let mut sent = false;
|
||||
note_dirty(&dirty, true, || {
|
||||
sent = true;
|
||||
true
|
||||
});
|
||||
assert!(!sent);
|
||||
assert_eq!(dirty.get(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_message_types_name_no_kind() {
|
||||
for bad in
|
||||
["", ":", "pdfcraft", "pdfcraft:", ":open", "pdfcraft:open:extra", "pdfcraft:OPEN", "pdfcraft: open", "pdfcraftx:open", "pdfcraft/open"]
|
||||
{
|
||||
assert_eq!(Incoming::parse("pdfcraft", bad), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:open"), Some(Incoming::Open));
|
||||
// The app's own outgoing kinds are not messages to it.
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:save"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_file_names_with_hostile_parts_are_cleaned() {
|
||||
// Only control characters, or only separators: nothing is left, so the fallback.
|
||||
assert_eq!(file_name(Some("\u{0}\u{7}\n\t"), "Drawing.dxf"), "Drawing.dxf");
|
||||
assert_eq!(file_name(Some("///"), "Drawing.dxf"), "Drawing.dxf");
|
||||
assert_eq!(file_name(Some(" "), "Drawing.dxf"), "Drawing.dxf");
|
||||
// Mixed separators and a control character inside the last part.
|
||||
assert_eq!(file_name(Some("a/b\\c\u{1b}[31m.txt"), "x"), "c[31m.txt");
|
||||
// Exactly at the limit stays whole; one over is cut; the limit counts characters, not bytes.
|
||||
assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS + 1)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
assert_eq!(file_name(Some(&"\u{1F4C4}".repeat(300)), "x"), "\u{1F4C4}".repeat(MAX_NAME_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_that_arrive_while_the_handler_runs_wait_their_turn() {
|
||||
use std::rc::Rc;
|
||||
type Shared = Rc<Inbox<&'static str, Box<dyn FnMut(&'static str)>>>;
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let inbox: Rc<RefCell<Option<Shared>>> = Rc::new(RefCell::new(None));
|
||||
let (log, me) = (seen.clone(), inbox.clone());
|
||||
let handler: Box<dyn FnMut(&'static str)> = Box::new(move |m: &'static str| {
|
||||
log.borrow_mut().push(format!("start {m}"));
|
||||
// The page answers inside the handler, as a synchronous dispatchEvent does.
|
||||
if m == "open"
|
||||
&& let Some(inbox) = me.borrow().clone()
|
||||
{
|
||||
inbox.deliver("saved");
|
||||
inbox.deliver("handoff-done");
|
||||
}
|
||||
log.borrow_mut().push(format!("end {m}"));
|
||||
});
|
||||
let shared = Rc::new(Inbox::new(handler));
|
||||
*inbox.borrow_mut() = Some(shared.clone());
|
||||
shared.deliver("open");
|
||||
assert_eq!(*seen.borrow(), ["start open", "end open", "start saved", "end saved", "start handoff-done", "end handoff-done"]);
|
||||
shared.deliver("host");
|
||||
assert_eq!(seen.borrow().len(), 8, "the inbox is usable again after draining");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! The bridge an app's web shell calls: find the host, hear its messages, post the app's.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! if let Some(host) = craft_web_host::web::Host::connect("pdfcraft", &canvas) {
|
||||
//! host.listen(move |message| inbox.push(message));
|
||||
//! host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
use web_sys::js_sys;
|
||||
|
||||
use crate::{Inbox, Incoming, Mode, Outgoing, VERSION, file_name, message_type};
|
||||
|
||||
/// A file the host passes in, not yet copied into the app's memory: check its size first.
|
||||
pub enum FileData {
|
||||
/// `bytes`: an `ArrayBuffer` or `Uint8Array`, viewed in place.
|
||||
Bytes(js_sys::Uint8Array),
|
||||
/// `file`: a `File` or `Blob`, for the app to read in slices.
|
||||
Blob(web_sys::Blob),
|
||||
}
|
||||
|
||||
impl FileData {
|
||||
/// The file's length in bytes, from the data itself (a host's `size` field is not trusted).
|
||||
pub fn size(&self) -> f64 {
|
||||
match self {
|
||||
Self::Bytes(array) => f64::from(array.length()),
|
||||
Self::Blob(blob) => blob.size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from the host.
|
||||
pub enum Message {
|
||||
/// What the host supports; `handoff`: it routes files the app hands off.
|
||||
Host { version: String, handoff: bool },
|
||||
/// Open a document as File › Open would. `name` is already cleaned ([`crate::file_name`]).
|
||||
Open { name: String, data: FileData },
|
||||
/// The save with this `id` is stored.
|
||||
Saved { id: String },
|
||||
/// The host gave a handed-off file to the app that edits it.
|
||||
HandoffDone { name: String, app: String },
|
||||
}
|
||||
|
||||
enum Transport {
|
||||
/// The same-origin parent window; only it is heard and answered.
|
||||
Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },
|
||||
/// The app's canvas; messages are `CustomEvent`s on it.
|
||||
Page { canvas: web_sys::EventTarget },
|
||||
}
|
||||
|
||||
/// The connection to the page hosting the app.
|
||||
pub struct Host {
|
||||
app: &'static str,
|
||||
transport: Transport,
|
||||
dirty: Cell<Option<bool>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// The host for `app` (its id, as in `<app>:ready`) running on `canvas`, or `None` when the
|
||||
/// app is not hosted: no `?host=parent` and no `data-host="page"`, no framing parent, or a
|
||||
/// parent on another origin (messages only go to this origin, so the browser would drop them).
|
||||
pub fn connect(app: &'static str, canvas: &web_sys::Element) -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let query = window.location().search().unwrap_or_default();
|
||||
let transport = match Mode::detect(&query, canvas.get_attribute("data-host").as_deref())? {
|
||||
Mode::Parent => {
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Transport::Parent { window, parent, origin }
|
||||
}
|
||||
Mode::Page => Transport::Page { canvas: canvas.clone().into() },
|
||||
};
|
||||
Some(Self { app, transport, dirty: Cell::new(None) })
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
match self.transport {
|
||||
Transport::Parent { .. } => Mode::Parent,
|
||||
Transport::Page { .. } => Mode::Page,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hear the host's messages for this app. The listener lives as long as the page. The
|
||||
/// listeners are `Fn`: a page host can dispatch an event while one is still running (a
|
||||
/// `saved` for the `save` the handler just posted), and wasm-bindgen refuses to re-enter an
|
||||
/// `FnMut` closure before its body runs, so the message would never reach the queue.
|
||||
pub fn listen(&self, handler: impl FnMut(Message) + 'static) {
|
||||
let app = self.app;
|
||||
let inbox = Rc::new(Inbox::new(handler));
|
||||
match &self.transport {
|
||||
Transport::Parent { window, parent, origin } => {
|
||||
let (parent, origin, inbox) = (parent.clone(), origin.clone(), inbox.clone());
|
||||
let on_message = Closure::<dyn Fn(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
let Some(kind) = string(&data, "type").and_then(|t| Incoming::parse(app, &t)) else { return };
|
||||
if let Some(message) = read(kind, &data) {
|
||||
inbox.deliver(message);
|
||||
}
|
||||
});
|
||||
if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
Transport::Page { canvas } => {
|
||||
for kind in Incoming::ALL {
|
||||
let inbox = inbox.clone();
|
||||
let on_event = Closure::<dyn Fn(web_sys::CustomEvent)>::new(move |event: web_sys::CustomEvent| {
|
||||
if let Some(message) = read(kind, &event.detail()) {
|
||||
inbox.deliver(message);
|
||||
}
|
||||
});
|
||||
let name = message_type(app, kind.kind());
|
||||
if canvas.add_event_listener_with_callback(&name, on_event.as_ref().unchecked_ref()).is_ok() {
|
||||
on_event.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The app is listening: send a document now. `app_version` is the app's own release;
|
||||
/// `reads_files`: it takes a `File`/`Blob` and reads it in slices.
|
||||
pub fn post_ready(&self, app_version: &str, reads_files: bool) -> Result<(), String> {
|
||||
self.post(Outgoing::Ready, &[("version", VERSION.into()), ("appVersion", app_version.into()), ("file", reads_files.into())], None)
|
||||
}
|
||||
|
||||
/// Unsaved changes appeared or went away. Repeats of the last state reported are not sent.
|
||||
/// The new state is recorded before the send: a page host can report another state while the
|
||||
/// event is still dispatching, and that later report wins. A failed send is forgotten.
|
||||
pub fn post_dirty(&self, dirty: bool) {
|
||||
crate::note_dirty(&self.dirty, dirty, || self.post(Outgoing::Dirty, &[("dirty", dirty.into())], None).is_ok());
|
||||
}
|
||||
|
||||
/// Hand the host a file the app would otherwise download: Save, Save As, Export. With an
|
||||
/// `id`, the host answers [`Message::Saved`] once it is stored; without one the save is done
|
||||
/// once it is posted. The bytes go as a transferred `ArrayBuffer`.
|
||||
pub fn post_save(&self, name: &str, bytes: &[u8], id: Option<&str>, save_as: bool) -> Result<(), String> {
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let mut fields =
|
||||
vec![("name", JsValue::from(file_name(Some(name), "document"))), ("bytes", buffer.clone().into()), ("saveAs", save_as.into())];
|
||||
if let Some(id) = id {
|
||||
fields.push(("id", id.into()));
|
||||
}
|
||||
self.post(Outgoing::Save, &fields, Some(&buffer))
|
||||
}
|
||||
|
||||
/// The app couldn't start, couldn't take a document, or a save went unconfirmed (`id`).
|
||||
pub fn post_failed(&self, error: &str, id: Option<&str>) -> Result<(), String> {
|
||||
let mut fields = vec![("error", JsValue::from(error))];
|
||||
if let Some(id) = id {
|
||||
fields.push(("id", id.into()));
|
||||
}
|
||||
self.post(Outgoing::Failed, &fields, None)
|
||||
}
|
||||
|
||||
/// Pass the host a file another app edits; it answers [`Message::HandoffDone`] or, when no
|
||||
/// app it hosts takes the type, `failed`. Without a host, tell the user which app the file
|
||||
/// needs instead.
|
||||
pub fn post_handoff(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
self.post(Outgoing::Handoff, &[("name", file_name(Some(name), "document").into()), ("bytes", buffer.clone().into())], Some(&buffer))
|
||||
}
|
||||
|
||||
fn post(&self, kind: Outgoing, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {
|
||||
let message_type = message_type(self.app, kind.kind());
|
||||
let error = |e: JsValue| e.as_string().unwrap_or_else(|| format!("{e:?}"));
|
||||
match &self.transport {
|
||||
Transport::Parent { parent, origin, .. } => {
|
||||
let message = object(&[("type", message_type.as_str().into())], fields)?;
|
||||
match transfer {
|
||||
Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),
|
||||
None => parent.post_message(&message, origin),
|
||||
}
|
||||
.map_err(error)
|
||||
}
|
||||
Transport::Page { canvas } => {
|
||||
let init = web_sys::CustomEventInit::new();
|
||||
init.set_detail(&object(&[], fields)?.into());
|
||||
let event = web_sys::CustomEvent::new_with_event_init_dict(&message_type, &init).map_err(error)?;
|
||||
canvas.dispatch_event(&event).map(|_| ()).map_err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The message a host's object describes, or `None` when a required field is missing.
|
||||
fn read(kind: Incoming, data: &JsValue) -> Option<Message> {
|
||||
Some(match kind {
|
||||
Incoming::Host => Message::Host {
|
||||
version: string(data, "version").unwrap_or_else(|| VERSION.to_string()),
|
||||
handoff: get(data, "handoff").as_bool() == Some(true),
|
||||
},
|
||||
Incoming::Open => {
|
||||
let name = file_name(string(data, "name").as_deref(), "document");
|
||||
Message::Open { name, data: file_data(data)? }
|
||||
}
|
||||
Incoming::Saved => Message::Saved { id: string(data, "id")? },
|
||||
Incoming::HandoffDone => {
|
||||
Message::HandoffDone { name: string(data, "name").unwrap_or_default(), app: string(data, "app").unwrap_or_default() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `file` (a `Blob`; its `size` field is ignored for the Blob's own) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied.
|
||||
fn file_data(data: &JsValue) -> Option<FileData> {
|
||||
let file = get(data, "file");
|
||||
if let Some(blob) = file.dyn_ref::<web_sys::Blob>() {
|
||||
return Some(FileData::Blob(blob.clone()));
|
||||
}
|
||||
let bytes = get(data, "bytes");
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(FileData::Bytes(array.clone()))
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(FileData::Bytes(js_sys::Uint8Array::new(&bytes)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn object(head: &[(&str, JsValue)], fields: &[(&str, JsValue)]) -> Result<js_sys::Object, String> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in head.iter().chain(fields) {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).map_err(|_| "couldn't build the message".to_string())?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn get(data: &JsValue, key: &str) -> JsValue {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
get(data, key).as_string()
|
||||
}
|
||||
|
||||
/// Node tests for the parsing of a host's objects: wrong shapes, missing fields, and sizes the
|
||||
/// host states against the data it sends. No browser; run with `wasm-bindgen-test-runner`.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasm_bindgen_test::wasm_bindgen_test;
|
||||
|
||||
fn obj(fields: &[(&str, JsValue)]) -> JsValue {
|
||||
object(&[], fields).expect("an object").into()
|
||||
}
|
||||
|
||||
fn buffer(len: u32) -> js_sys::ArrayBuffer {
|
||||
js_sys::ArrayBuffer::new(len)
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn an_open_without_bytes_or_a_blob_is_dropped() {
|
||||
for data in [
|
||||
obj(&[("name", "a.pdf".into())]),
|
||||
obj(&[("name", "a.pdf".into()), ("bytes", "text".into())]),
|
||||
obj(&[("name", "a.pdf".into()), ("bytes", 42.into())]),
|
||||
obj(&[("name", "a.pdf".into()), ("file", "not a blob".into())]),
|
||||
JsValue::NULL,
|
||||
JsValue::UNDEFINED,
|
||||
JsValue::from_str("a string"),
|
||||
JsValue::from_f64(7.0),
|
||||
] {
|
||||
assert!(read(Incoming::Open, &data).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn an_open_takes_bytes_as_an_array_buffer_or_a_uint8_array() {
|
||||
let ab = buffer(3);
|
||||
let array = js_sys::Uint8Array::new_with_length(5);
|
||||
for (bytes, len) in [(JsValue::from(ab), 3.0), (JsValue::from(array), 5.0)] {
|
||||
let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("name", "a.pdf".into()), ("bytes", bytes)])) else {
|
||||
panic!("an open with bytes is read");
|
||||
};
|
||||
assert_eq!(data.size(), len);
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn an_open_without_a_name_or_with_a_hostile_one_gets_a_clean_name() {
|
||||
let named = |name: Option<&str>| {
|
||||
let mut fields = vec![("bytes", JsValue::from(buffer(1)))];
|
||||
if let Some(n) = name {
|
||||
fields.push(("name", n.into()));
|
||||
}
|
||||
match read(Incoming::Open, &obj(&fields)) {
|
||||
Some(Message::Open { name, .. }) => name,
|
||||
_ => panic!("an open with bytes is read"),
|
||||
}
|
||||
};
|
||||
assert_eq!(named(None), "document");
|
||||
assert_eq!(named(Some("../../etc/passwd")), "passwd");
|
||||
assert_eq!(named(Some("a\u{0}b.txt")), "ab.txt");
|
||||
assert_eq!(named(Some(&"x".repeat(300))).chars().count(), crate::MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn the_size_is_the_data_s_own_not_the_hosts_claim() {
|
||||
// An ArrayBuffer of 64 MiB with a `size` field of 1: the bytes are viewed in place, not
|
||||
// copied, and the app sees their real length before it decides to read them.
|
||||
let ab = buffer(64 << 20);
|
||||
let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("bytes", ab.clone().into()), ("size", 1.into())])) else {
|
||||
panic!("an open with bytes is read");
|
||||
};
|
||||
assert_eq!(data.size(), f64::from(64u32 << 20));
|
||||
let FileData::Bytes(view) = &data else { panic!("bytes stay bytes") };
|
||||
assert!(js_sys::Object::is(&view.buffer(), &ab), "a view of the host's buffer, not a copy");
|
||||
|
||||
// A Blob of 5 bytes with a `size` field of a terabyte.
|
||||
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&"hello".into())).expect("a Blob");
|
||||
let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("file", blob.into()), ("size", 1e12.into())])) else {
|
||||
panic!("an open with a Blob is read");
|
||||
};
|
||||
assert_eq!(data.size(), 5.0);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn a_saved_needs_a_string_id() {
|
||||
assert!(matches!(read(Incoming::Saved, &obj(&[("id", "s1".into())])), Some(Message::Saved { id }) if id == "s1"));
|
||||
for data in [obj(&[]), obj(&[("id", 3.into())]), JsValue::NULL, JsValue::from_str("s1")] {
|
||||
assert!(read(Incoming::Saved, &data).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn a_host_message_and_a_handoff_done_read_whatever_fields_exist() {
|
||||
// Nothing required: a missing version is the contract's, `handoff` is true only for `true`.
|
||||
for data in [JsValue::NULL, obj(&[]), obj(&[("handoff", "yes".into())]), obj(&[("handoff", 1.into())])] {
|
||||
let Some(Message::Host { version, handoff }) = read(Incoming::Host, &data) else { panic!("a host message is read") };
|
||||
assert_eq!(version, crate::VERSION);
|
||||
assert!(!handoff);
|
||||
}
|
||||
assert!(matches!(read(Incoming::Host, &obj(&[("handoff", true.into())])), Some(Message::Host { handoff: true, .. })));
|
||||
assert!(matches!(
|
||||
read(Incoming::HandoffDone, &obj(&[("name", "a.pdf".into()), ("app", "pdfcraft".into())])),
|
||||
Some(Message::HandoffDone { name, app }) if name == "a.pdf" && app == "pdfcraft"
|
||||
));
|
||||
assert!(matches!(read(Incoming::HandoffDone, &JsValue::NULL), Some(Message::HandoffDone { name, app }) if name.is_empty() && app.is_empty()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user