mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 11:03:15 +00:00
figcraft-web: speak the host messages through craft-web-host
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); FigCraft keeps what is its own: which saved files become the document, the open size limit and the author.
This commit is contained in:
1 parent
845e34dfeb
commit
cb9a63807e
6 files changed
+86
-235
No files matched your search
Generated
+10
@@ -1157,6 +1157,15 @@ dependencies = [
|
||||
"zip 9.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2121,6 +2130,7 @@ name = "figcraft-web"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
|
||||
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
|
||||
figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
base64 = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
+52
-192
@@ -1,23 +1,21 @@
|
||||
//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The same
|
||||
//! messages as the other Crafting Apps' bridges.
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages
|
||||
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
|
||||
//! the other Craft apps' bridges. This module is FigCraft's part of it.
|
||||
//!
|
||||
//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
|
||||
//!
|
||||
//! The framing page and FigCraft talk over `postMessage` between same-origin windows. Messages
|
||||
//! are plain objects with a `type`:
|
||||
//!
|
||||
//! - `figcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `figcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens exactly as a file picked with File › Open or dropped on the window
|
||||
//! would (the app's own open queue, with its "Opening as a new file" card). A payload over the
|
||||
//! browser's own open limit ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before
|
||||
//! its bytes are copied, and anything malformed is ignored.
|
||||
//! - `figcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file the app hands its download service — in host mode nothing reaches the browser's
|
||||
//! downloads. The parent stores it and reports failures itself. `saveAs` says whether the file
|
||||
//! becomes the document's file ([`crate::hostmode::save_as`]):
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document) uses DOM
|
||||
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
|
||||
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
|
||||
//! - `figcraft:open` (from the host): `{ name, bytes }`. It opens exactly as a file picked with
|
||||
//! File › Open or dropped on the window would (the app's own open queue, with its "Opening as
|
||||
//! a new file" card). A payload over the browser's own open limit
|
||||
//! ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before its bytes are copied,
|
||||
//! and anything malformed is ignored. FigCraft reads whole files, so it says `file: false` in
|
||||
//! `ready` and a host sends `bytes`.
|
||||
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
|
||||
//! download service; in host mode nothing reaches the browser's downloads. `saveAs` says whether
|
||||
//! the file becomes the document's file ([`crate::hostmode::save_as`]):
|
||||
//! - A Save of the file the host opened, under its own name: `false`.
|
||||
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
|
||||
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
|
||||
@@ -26,31 +24,20 @@
|
||||
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
|
||||
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
|
||||
//! sidecar's `.json` extension doesn't make it a document).
|
||||
//! - `figcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `figcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered.
|
||||
//!
|
||||
//! ## `data-host="page"` — the page's own document
|
||||
//!
|
||||
//! A page that loads the module into its own document (its own `<canvas id="figcraft_canvas"
|
||||
//! data-host="page">`, no iframe) gets the same behaviour over DOM `CustomEvent`s on the canvas
|
||||
//! instead of `postMessage`: the same four events outward (`ready`, `save`, `dirty`, `failed`,
|
||||
//! with the message fields as the event's `detail`), and the page dispatches `figcraft:open` on
|
||||
//! the canvas with `detail: { name, bytes }` to open a document. Events don't bubble, so two
|
||||
//! editors on one page can't hear each other, and the canvas is the only target FigCraft
|
||||
//! listens on.
|
||||
//! - `figcraft:dirty` whenever unsaved changes appear or go away, `figcraft:failed` when the app
|
||||
//! couldn't start.
|
||||
//!
|
||||
//! In host mode the app starts with no document (the home screen) and the host sends the file to
|
||||
//! edit; `?author=` names the user for comments and board notes.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
/// The app id in the host messages (`figcraft:ready`).
|
||||
const APP: &str = "figcraft";
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
@@ -59,88 +46,20 @@ const MAX_AUTHOR_CHARS: usize = 200;
|
||||
/// `(name, bytes)`.
|
||||
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// How a host page is reached. The modes behave the same; they differ only in how an event goes
|
||||
/// out ([`Transport::send`]) and how an open request comes in ([`Bridge::listen`]).
|
||||
#[derive(Clone)]
|
||||
enum Transport {
|
||||
/// `?host=parent`: `postMessage` between same-origin windows.
|
||||
Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },
|
||||
/// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.
|
||||
Page { canvas: web_sys::HtmlCanvasElement },
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
/// Hand an event to the host. In parent mode this is a `postMessage` whose object carries a
|
||||
/// `type` field; in page mode a `CustomEvent` of that name on the canvas, whose `detail`
|
||||
/// holds the fields and which never bubbles (two editors on one page must not hear each
|
||||
/// other). `transfer` hands an `ArrayBuffer` to the parent instead of copying it (parent
|
||||
/// mode only; the page's document already shares the buffer's bytes).
|
||||
fn send(&self, event: &str, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {
|
||||
match self {
|
||||
Transport::Parent { parent, origin, .. } => {
|
||||
let mut message_fields = Vec::with_capacity(fields.len() + 1);
|
||||
message_fields.push(("type", JsValue::from_str(event)));
|
||||
message_fields.extend_from_slice(fields);
|
||||
let message = message(&message_fields).ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
let sent = match transfer {
|
||||
Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),
|
||||
None => parent.post_message(&message, origin),
|
||||
};
|
||||
sent.map_err(|e| js_error(&e, "couldn't post the message"))
|
||||
}
|
||||
Transport::Page { canvas } => {
|
||||
let detail = message(fields).ok_or_else(|| "couldn't build the event detail".to_string())?;
|
||||
let init = web_sys::CustomEventInit::new();
|
||||
// The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in web-sys
|
||||
// 0.3.106 and fail clippy -D warnings; use the setters.
|
||||
init.set_bubbles(false);
|
||||
init.set_detail(&detail);
|
||||
let custom = web_sys::CustomEvent::new_with_event_init_dict(event, &init).map_err(|e| js_error(&e, "couldn't create the event"))?;
|
||||
// A listener calling `preventDefault` would be the bool; not a send failure.
|
||||
canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
transport: Transport,
|
||||
host: Rc<Host>,
|
||||
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
|
||||
/// Save under (`saveAs: false`) is decided against.
|
||||
host_file: Rc<RefCell<Option<String>>>,
|
||||
/// The unsaved-changes state last reported to the host.
|
||||
reported: Rc<Cell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The parent bridge for `?host=parent`. `None` when the page isn't framed (there is no
|
||||
/// parent to talk to), so opening that URL directly stays the normal app with downloads, or
|
||||
/// when the parent is on another origin: messages only go to this origin, so the browser
|
||||
/// would drop every save without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { transport: Transport::Parent { window, parent, origin }, host_file: Rc::default(), reported: Rc::default() })
|
||||
}
|
||||
|
||||
/// The page bridge for `data-host="page"`: the host page runs FigCraft in its own document
|
||||
/// and hears the editor's DOM `CustomEvent`s on this canvas.
|
||||
pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {
|
||||
Self { transport: Transport::Page { canvas: canvas.clone() }, host_file: Rc::default(), reported: Rc::default() }
|
||||
/// The bridge for this page, or `None` when FigCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: Rc::default() })
|
||||
}
|
||||
|
||||
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
|
||||
@@ -148,37 +67,11 @@ impl Bridge {
|
||||
/// opens like a dropped one).
|
||||
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
|
||||
let host_file = self.host_file.clone();
|
||||
match &self.transport {
|
||||
Transport::Parent { window, parent, origin } => {
|
||||
let parent = parent.clone();
|
||||
let origin = origin.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();
|
||||
if string(&data, "type").as_deref() != Some("figcraft:open") {
|
||||
return;
|
||||
}
|
||||
deliver_open(&opens, &ctx, &host_file, &data);
|
||||
});
|
||||
if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&opens, &ctx, &host_file, &name, &data);
|
||||
}
|
||||
Transport::Page { canvas } => {
|
||||
let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
|
||||
// A plain `Event` of the same name carries no detail; only a `CustomEvent`.
|
||||
let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };
|
||||
deliver_open(&opens, &ctx, &host_file, &open.detail());
|
||||
});
|
||||
if canvas.add_event_listener_with_callback("figcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_open.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and board notes.
|
||||
@@ -190,12 +83,12 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.transport.send("figcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.transport.send("figcraft:failed", &[("error", error.into())], None);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// Hand a file the app would otherwise download to the host; `false` when nothing was
|
||||
@@ -204,10 +97,7 @@ impl Bridge {
|
||||
let name = file_name(path);
|
||||
let host_file = self.host_file.borrow().clone();
|
||||
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
|
||||
// `figcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let fields: [(&str, JsValue); 3] = [("name", name.as_str().into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];
|
||||
let sent = self.transport.send("figcraft:save", &fields, Some(&buffer)).is_ok();
|
||||
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
|
||||
// After a Save As hand-over the host has the new file: the next Save of it sends its name
|
||||
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
|
||||
if sent && save_as {
|
||||
@@ -216,63 +106,33 @@ impl Bridge {
|
||||
sent
|
||||
}
|
||||
|
||||
/// Report unsaved changes when they appear or go away, not every frame.
|
||||
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
|
||||
/// repeat a state it already reported).
|
||||
pub fn set_dirty(&self, dirty: bool) {
|
||||
if self.reported.get() != Some(dirty) && self.transport.send("figcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
|
||||
self.reported.set(Some(dirty));
|
||||
}
|
||||
self.host.post_dirty(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file an open message or event carries, queued for the app's own open path, or nothing when
|
||||
/// the payload isn't one (no bytes, or over the open limit).
|
||||
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, data: &JsValue) {
|
||||
let name = crate::hostmode::open_name(string(data, "name").as_deref());
|
||||
let Some(bytes) = payload_bytes(data, &name) else { return };
|
||||
/// The file an open message carries, queued for the app's own open path, or nothing when the
|
||||
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
|
||||
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
|
||||
let name = crate::hostmode::open_name(name);
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
|
||||
log::warn!("host file {name} is too large to open in the browser; not opened");
|
||||
return;
|
||||
}
|
||||
// The document's file is now the one the host sent: a Save of it under this name is
|
||||
// `saveAs: false`.
|
||||
*host_file.borrow_mut() = Some(name.clone());
|
||||
opens.borrow_mut().push((name, bytes));
|
||||
opens.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The `bytes` of an open message or event as owned bytes, refused over the browser's own open
|
||||
/// limit ([`figcraft_ui_egui::browser_can_read`]) before they are copied. `bytes` is an
|
||||
/// `ArrayBuffer` or `Uint8Array`.
|
||||
fn payload_bytes(data: &JsValue, name: &str) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
let len = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
array.length() as f64
|
||||
} else {
|
||||
bytes.dyn_ref::<js_sys::ArrayBuffer>()?.byte_length() as f64
|
||||
};
|
||||
if figcraft_ui_egui::browser_can_read(name, len).is_err() {
|
||||
log::warn!("host file {name} is too large to open in the browser; not opened");
|
||||
return None;
|
||||
}
|
||||
Some(if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() { array.to_vec() } else { js_sys::Uint8Array::new(&bytes).to_vec() })
|
||||
}
|
||||
|
||||
/// The last component of `path`, or the whole of it when there is none.
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// A message object or `CustomEvent` detail with the given fields, or `None` if one couldn't be
|
||||
/// built (a field that isn't a valid property name, say).
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// A `JsValue` error as a string, after `what` went wrong.
|
||||
fn js_error(e: &JsValue, what: &str) -> String {
|
||||
format!("{what}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::query`]): what opts a page into hosting FigCraft in its own document, the name
|
||||
//! the bridge fills in when an open request has none, and whether a handed-over file becomes the
|
||||
//! document's file.
|
||||
|
||||
/// The one `data-host` value that opts a page in: the exact string `"page"`.
|
||||
pub const PAGE_HOST: &str = "page";
|
||||
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
|
||||
//! a handed-over file becomes the document's file. Which pages host the app is `craft-web-host`'s.
|
||||
|
||||
/// The file name used when an open request carries no usable one.
|
||||
pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
|
||||
@@ -19,17 +15,14 @@ const DOC_EXTS: &[&str] = &["figcraft", "json"];
|
||||
/// document, so its extension alone can't tell the `saveAs` decision.
|
||||
const VARIABLES_SIDECAR: &str = ".variables.json";
|
||||
|
||||
/// Does this `data-host` value ask for page host mode? Only the exact value [`PAGE_HOST`] counts;
|
||||
/// anything else is ignored, so the page host mode stays opt-in. `?host=parent`, the parent
|
||||
/// bridge's URL opt-in, is answered before this is asked.
|
||||
pub fn page_host_requested(data_host: Option<&str>) -> bool {
|
||||
data_host == Some(PAGE_HOST)
|
||||
}
|
||||
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
|
||||
/// name and falls back to this).
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// A usable file name for an open request: the host's name when it isn't blank, else the
|
||||
/// [`DEFAULT_OPEN_NAME`].
|
||||
pub fn open_name(name: Option<&str>) -> String {
|
||||
name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME).to_string()
|
||||
/// A usable file name for an open request: the cleaned name the crate hands over, or
|
||||
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
|
||||
pub fn open_name(name: &str) -> String {
|
||||
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
|
||||
@@ -61,20 +54,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_exact_page_value_opts_in() {
|
||||
assert!(page_host_requested(Some("page")));
|
||||
for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] {
|
||||
assert!(!page_host_requested(off), "{off:?} must keep the page host mode off");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_names_fall_back_to_the_default_when_blank() {
|
||||
assert_eq!(open_name(Some("Poster.figcraft")), "Poster.figcraft");
|
||||
assert_eq!(open_name(Some(" Poster.figcraft ")), " Poster.figcraft ", "a name with its spacing is kept");
|
||||
assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);
|
||||
assert_eq!(open_name(Some(" ")), DEFAULT_OPEN_NAME);
|
||||
assert_eq!(open_name(None), DEFAULT_OPEN_NAME);
|
||||
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
|
||||
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,18 +32,12 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes: `?host=parent`, a same-origin page framing FigCraft, opens documents in it
|
||||
// and stores what it saves (`host`); unframed there is no parent, so that URL on its own
|
||||
// stays the normal app with downloads. `data-host="page"` on the canvas opts a page in
|
||||
// instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on the
|
||||
// canvas (`host`).
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") {
|
||||
crate::host::Bridge::new()
|
||||
} else if crate::hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {
|
||||
Some(crate::host::Bridge::for_page(&canvas))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FigCraft, opens
|
||||
// documents in it and stores what it saves (`host`); unframed there is no parent, so that URL
|
||||
// on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a
|
||||
// page in instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on
|
||||
// the canvas.
|
||||
let bridge = crate::host::Bridge::connect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
// Files the host sends, on their way into the app's own open queue each frame.
|
||||
let opens: crate::host::OpenQueue = std::rc::Rc::default();
|
||||
|
||||
@@ -123,7 +123,7 @@ unsaved changes. Two opt-in transports carry the same messages:
|
||||
|
||||
| Message | Direction | Fields |
|
||||
|---|---|---|
|
||||
| `figcraft:ready` | app → page | `version`: the app is running and listening; send a file now |
|
||||
| `figcraft:ready` | app → page | `version`, `appVersion`: the app is running and listening; send a file now |
|
||||
| `figcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open (a payload over the browser open limit of 512 MB is refused; anything malformed is ignored) |
|
||||
| `figcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save of the file the page sent, under its own name; `true` for a document file (`.figcraft`, `.json`) under a name the page didn't send — Save As, or a Save of a document the page never sent — after which the next Save carries that name with `false`; `false` for copies and exports that don't become the document (rendered images, SVG, PDF, `.fig` copies, the variables exports) |
|
||||
| `figcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
|
||||
@@ -197,6 +197,8 @@ await init({ module_or_path: "./figcraft-web-<hash>_bg.wasm" });
|
||||
present it takes precedence, so a same-origin framed page uses the parent bridge, while an
|
||||
unframed or cross-origin page gets no bridge and keeps normal downloads.
|
||||
|
||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `figcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. FigCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Trust and limits
|
||||
|
||||
- **Who is trusted.** In page host mode the page and FigCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
|
||||
|
||||
Reference in new issue
Block a user