mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 20:42:12 +00:00
craft-web-host: the host messages as one contract and one bridge
The messages between a Craft app's web build and the page that hosts it (ready, host, open, save, saved, dirty, failed, handoff, handoff-done), as proposed on #358. The contract part (message kinds, the version, the ?host=parent / data-host="page" switch, file-name clean-up) builds everywhere and is tested natively; the web module (wasm32) is the bridge an app's web shell calls: same-origin parent only, files as bytes or a Blob without copying, saves as a transferred ArrayBuffer, dirty repeats suppressed. Compatible with the bridges shipped before it: optional fields stay optional, and a host treats an unknown ready.version as 1.
This commit is contained in:
1 parent
d836bea31b
commit
da0b274a28
7 files changed
+498
No files matched your search
Generated
+8
@@ -1192,6 +1192,14 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -53,6 +53,7 @@ craft-ui = { path = "crates/ui" }
|
||||
craft-update = { path = "crates/update" }
|
||||
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
|
||||
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,7 @@ 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-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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[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"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
- `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 side of the same contract is `@craft/app` (storytold/craft-libs#413), which mounts a web build and speaks these messages.
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 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.
|
||||
|
||||
#[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()
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 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, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
use web_sys::js_sys;
|
||||
|
||||
use crate::{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` + `size`: a `File` or `Blob`, for the app to read in slices.
|
||||
Blob { blob: web_sys::Blob, size: f64 },
|
||||
}
|
||||
|
||||
impl FileData {
|
||||
/// The file's length in bytes.
|
||||
pub fn size(&self) -> f64 {
|
||||
match self {
|
||||
Self::Bytes(array) => f64::from(array.length()),
|
||||
Self::Blob { size, .. } => *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.
|
||||
pub fn listen(&self, handler: impl FnMut(Message) + 'static) {
|
||||
let app = self.app;
|
||||
let handler = Rc::new(RefCell::new(handler));
|
||||
match &self.transport {
|
||||
Transport::Parent { window, parent, origin } => {
|
||||
let (parent, origin, handler) = (parent.clone(), origin.clone(), handler.clone());
|
||||
let on_message = Closure::<dyn FnMut(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) {
|
||||
(handler.borrow_mut())(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 handler = handler.clone();
|
||||
let on_event = Closure::<dyn FnMut(web_sys::CustomEvent)>::new(move |event: web_sys::CustomEvent| {
|
||||
if let Some(message) = read(kind, &event.detail()) {
|
||||
(handler.borrow_mut())(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.
|
||||
pub fn post_dirty(&self, dirty: bool) {
|
||||
if self.dirty.get() != Some(dirty) && self.post(Outgoing::Dirty, &[("dirty", dirty.into())], None).is_ok() {
|
||||
self.dirty.set(Some(dirty));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`, with `size`) 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>() {
|
||||
let size = get(data, "size").as_f64().unwrap_or_else(|| blob.size());
|
||||
return Some(FileData::Blob { blob: blob.clone(), size });
|
||||
}
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user