filmcraft-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); FilmCraft keeps what is its own: project or media, the open size limit and the saveAs rule.
This commit is contained in:
Helen Kwok
2026-11-05 17:50:58 +08:00
parent 86e32a6dfe
commit 910c65cd6a
7 changed files with 91 additions and 268 deletions
Generated
+10
View File
@@ -1146,6 +1146,15 @@ dependencies = [
"zip 9.1.0",
]
[[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 = "crc32fast"
version = "1.5.2"
@@ -2584,6 +2593,7 @@ dependencies = [
name = "filmcraft-web"
version = "0.17.0"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"filmcraft-codecs",
+2
View File
@@ -16,6 +16,8 @@ crate-type = ["cdylib", "rlib"]
# and clippy stay cheap). Build with `cargo xtask web` (see docs/web.md).
[target.'cfg(target_arch = "wasm32")'.dependencies]
filmcraft-engine = { 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" }
filmcraft-ui-egui = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-codecs = { workspace = true }
+51 -219
View File
@@ -1,39 +1,22 @@
//! Host modes: a page that runs FilmCraft (as an `<iframe>`, or in its own document) opens
//! projects in it and stores what it saves instead of the browser downloading. The same
//! messages as the other Craft apps' bridges (WordCraft, GridCraft, PdfCraft), with the
//! `filmcraft:` prefix.
//! projects 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, with the `filmcraft:` prefix. This module is FilmCraft's part of it.
//!
//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
//!
//! The framing page and FilmCraft talk over `postMessage` between same-origin windows. Messages
//! are plain objects with a `type`:
//!
//! - `filmcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `filmcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. A `.fcproj` opens like a project picked with File › Open or dropped on the
//! page; any other file imports like a dropped one. Over [`MAX_OPEN_BYTES`] (512 MiB, refused
//! before any byte is copied) or malformed: a message in the app's status bar, never a crash.
//! - `filmcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`)
//! for every file FilmCraft would otherwise download — see the `saveAs` rule below. The
//! parent stores it and reports failures itself.
//! - `filmcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away
//! (the session's own unsaved-changes state, reported only when it changes).
//! - `filmcraft: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. With no parent, or a
//! parent on another origin (messages to it are dropped silently, so saves would vanish while
//! the document looked saved), there is no bridge: the normal app with downloads.
//!
//! ## `data-host="page"` — the page's own document
//!
//! A page that loads the module into its own document (its own `<canvas id="filmcraft_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 `filmcraft:open`
//! on the canvas with `detail: { name, bytes }` to open a file. Events don't bubble, so two
//! editors on one page can't hear each other, and the canvas is the only target FilmCraft
//! listens on.
//! - `?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. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `filmcraft:ready` once the app is running and listening; send a file now. FilmCraft reads
//! bytes, so it says `file: false` and a host sends `bytes`.
//! - `filmcraft:open` (from the host): `{ name, bytes }`. A `.fcproj` opens like a project picked
//! with File › Open or dropped on the page; any other file imports like a dropped one. Over
//! [`MAX_OPEN_BYTES`] (512 MiB, refused before any byte is copied) or malformed: a message in
//! the app's status bar, never a crash.
//! - `filmcraft:save` (to the host): `{ name, saveAs, bytes }` for every file FilmCraft would
//! otherwise download — see the `saveAs` rule below.
//! - `filmcraft:dirty` whenever unsaved changes appear or go away (the session's own
//! unsaved-changes state), `filmcraft:failed` when the app couldn't start.
//!
//! ## The `saveAs` rule
//!
@@ -64,103 +47,36 @@
//! unsaved. FilmCraft has no user names that work could be signed with, so there is no
//! `?author=`.
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use filmcraft_engine::Session;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use crate::hostmode::{MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok};
/// 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::install`]).
#[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"))?;
// The bool says whether a listener called `preventDefault`; not a send failure.
canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;
Ok(())
}
}
}
}
/// The app id in the host messages (`filmcraft:ready`).
const APP: &str = "filmcraft";
/// The host bridge in host mode. Cloned into the write hook and the open listener; the state
/// they share is behind `Rc`.
#[derive(Clone)]
pub struct Bridge {
transport: Transport,
host: Rc<Host>,
/// Files written since the last frame: their paths (the bytes stay in the file table,
/// [`crate::fs::mem_bytes`], so a large export is never copied a second time).
written: Rc<RefCell<Vec<String>>>,
/// The document file the host knows: the project it opened here, and every file it was
/// sent with `saveAs: true`.
host_document: 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 }, written: Rc::default(), host_document: Rc::default(), reported: Rc::default() })
}
/// The page bridge for `data-host="page"`: the host page runs FilmCraft 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() }, written: Rc::default(), host_document: Rc::default(), reported: Rc::default() }
/// The bridge for this page, or `None` when FilmCraft 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)?), written: Rc::default(), host_document: Rc::default() })
}
/// Install the bridge: hear the host's files, and hand the app's writes to it instead of
@@ -173,22 +89,20 @@ impl Bridge {
/// After each frame's commands (the web app's per-frame logic pass): files written this
/// frame go to the host, with the `saveAs` decision from the session's state (the module
/// doc has the rule), and the host hears when unsaved changes appear or go away.
/// doc has the rule), and the host hears when unsaved changes appear or go away (the crate
/// does not repeat a state it already reported).
pub fn sync(&self, session: &Session) {
self.flush_saves(session);
let dirty = session.is_dirty();
if self.reported.get() != Some(dirty) && self.transport.send("filmcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
self.reported.set(Some(dirty));
}
self.host.post_dirty(session.is_dirty());
}
pub fn post_ready(&self) {
let _ = self.transport.send("filmcraft: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("filmcraft:failed", &[("error", error.into())], None);
let _ = self.host.post_failed(error, None);
}
/// The host's project opened: the host has this file as the document, so a Save of it goes
@@ -226,7 +140,7 @@ impl Bridge {
// a file right back.
let before = self.host_document.borrow().clone();
let save_as = is_save_as(&path, before.as_deref(), current.as_deref());
match self.post_save(&name, &bytes, save_as) {
match self.host.post_save(&name, &bytes, None, save_as) {
// The host has this file as the document now, unless it opened another one while
// handling the save; a failed hand-over changes nothing, so a retry is still a Save As.
Ok(()) if save_as => {
@@ -241,66 +155,31 @@ impl Bridge {
}
}
fn post_save(&self, name: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
// `filmcraft: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.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];
self.transport.send("filmcraft:save", &fields, Some(&buffer))
}
/// Hear `filmcraft:open` — from the same-origin parent (a `message` on the window) or from
/// the host document (a `filmcraft:open` event on the canvas) — and open or import the file
/// like one the user picked or dropped.
/// Hear `filmcraft:open` and open or import the file like one the user picked or dropped.
fn listen(&self) {
let bridge = self.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("filmcraft:open") {
return;
}
deliver_open(&bridge, &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(&bridge, &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(&bridge, &open.detail());
});
if canvas.add_event_listener_with_callback("filmcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_open.forget();
}
}
}
});
}
}
/// Handle a `filmcraft:open` payload: `{ name, bytes }`, bytes an `ArrayBuffer` or
/// `Uint8Array` — checked, then opened or imported like a file the user picked or dropped.
fn deliver_open(bridge: &Bridge, data: &JsValue) {
let Ok(raw) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return };
if !raw.is_instance_of::<js_sys::ArrayBuffer>() && raw.dyn_ref::<js_sys::Uint8Array>().is_none() {
return rejected_open("the bytes are neither an ArrayBuffer nor a Uint8Array".to_string());
}
let Some(len) = byte_length(&raw) else { return rejected_open("the bytes have no readable length".to_string()) };
/// Handle a `filmcraft:open` payload: checked, then opened or imported like a file the user
/// picked or dropped.
fn deliver_open(bridge: &Bridge, name: &str, data: &FileData) {
let FileData::Bytes(array) = data else {
return rejected_open("FilmCraft reads bytes, not a File".to_string());
};
// The data's own length, as a number: a 4 GiB view must not wrap to 0 and pass the check.
let len = data.size() as u64;
if !open_size_ok(len) {
return rejected_open(format!("{len} bytes is over the {} MiB host open limit", MAX_OPEN_BYTES >> 20));
}
let name = open_name(string(data, "name").as_deref());
let name = open_name(name);
if is_project(&name) {
let Some(bytes) = bytes_from(&raw) else { return rejected_open("couldn't read the bytes".to_string()) };
let bytes = array.to_vec();
let path = format!("/projects/{name}");
let opened = bridge.clone();
wasm_bindgen_futures::spawn_local(async move {
@@ -313,7 +192,7 @@ fn deliver_open(bridge: &Bridge, data: &JsValue) {
});
} else {
// Media registers like a dropped file: a `Blob` over the bytes, never copied whole.
let Some(blob) = blob_from_bytes(&raw) else { return rejected_open("couldn't wrap the bytes in a Blob".to_string()) };
let Some(blob) = blob_from_bytes(array) else { return rejected_open("couldn't wrap the bytes in a Blob".to_string()) };
wasm_bindgen_futures::spawn_local(async move { crate::import::import_blob(name, blob).await });
}
}
@@ -324,39 +203,11 @@ fn rejected_open(why: String) {
crate::post(move |w, _| w.app.status(format!("the host's file was not opened: {why}")));
}
/// The payload's `byteLength`, read as a number: the bindings' `u32` would wrap a 4 GiB view
/// to 0 and let it past the size check.
fn byte_length(v: &JsValue) -> Option<u64> {
let n = js_sys::Reflect::get(v, &JsValue::from_str("byteLength")).ok()?.as_f64()?;
if !n.is_finite() || n < 0.0 {
return None;
}
Some(n as u64)
}
/// The payload as Rust bytes (a project opens from them): one copy, from an `ArrayBuffer` or a
/// `Uint8Array`.
fn bytes_from(v: &JsValue) -> Option<Vec<u8>> {
if let Some(array) = v.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if v.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(v).to_vec())
} else {
None
}
}
/// The payload as a `Blob` (media registers like a dropped file): a view over the bytes, no
/// copy on the wasm side — the `Blob` constructor copies into its own store.
fn blob_from_bytes(v: &JsValue) -> Option<web_sys::Blob> {
fn blob_from_bytes(view: &js_sys::Uint8Array) -> Option<web_sys::Blob> {
let parts = js_sys::Array::new();
if let Some(view) = v.dyn_ref::<js_sys::Uint8Array>() {
parts.push(view);
} else if v.is_instance_of::<js_sys::ArrayBuffer>() {
parts.push(&js_sys::Uint8Array::new(v));
} else {
return None;
}
parts.push(view);
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/octet-stream");
web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).ok()
@@ -365,22 +216,3 @@ fn blob_from_bytes(v: &JsValue) -> Option<web_sys::Blob> {
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:?}")))
}
+11 -16
View File
@@ -1,9 +1,6 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser:
//! what opts a page into hosting FilmCraft in its own document, and the name and kind the
//! bridge reads out of an open request. Pure, so it is tested natively in `tests/it/hostmode.rs`.
/// The one `data-host` value that opts a page in: the exact string `"page"`.
pub const PAGE_HOST: &str = "page";
//! the name and kind the bridge reads out of an open request, and the `saveAs` rule. Which pages
//! host the app is `craft-web-host`'s. Pure, so it is tested natively in `tests/it/hostmode.rs`.
/// The file name used when an open request carries no usable one: an untitled project — what
/// a host that never names a file still opens (FilmCraft's document is its project file).
@@ -13,18 +10,16 @@ pub const DEFAULT_OPEN_NAME: &str = "Untitled.fcproj";
/// file dropped on the page.
pub const PROJECT_EXT: &str = ".fcproj";
/// 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`]. `/` and `\` become `_`, as [`crate::fs::register_blob`] does for
/// dropped names — the name becomes a path in the virtual file table.
pub fn open_name(name: Option<&str>) -> String {
let name = name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME);
/// A usable file name for an open request: the cleaned name the crate hands over, or
/// [`DEFAULT_OPEN_NAME`] when the host sent none. `/` and `\` become `_`, as
/// [`crate::fs::register_blob`] does for dropped names — the name becomes a path in the virtual
/// file table.
pub fn open_name(name: &str) -> String {
let name = if name == NAMELESS || name.trim().is_empty() { DEFAULT_OPEN_NAME } else { name };
name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect()
}
+7 -17
View File
@@ -133,10 +133,6 @@ fn query_flag(name: &str) -> bool {
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
}
fn query_param(name: &str) -> Option<String> {
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).and_then(|p| p.get(name))
}
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
#[wasm_bindgen]
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
@@ -145,19 +141,13 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
// Host modes: `?host=parent`, a same-origin page framing FilmCraft in an `<iframe>`, opens
// files 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 FilmCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas. In host mode the host sends the document to edit, so no demo project
// opens and nothing from an earlier visit is recovered.
let host = if query_param("host").as_deref() == Some("parent") {
host::Bridge::new()
} else if hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {
Some(host::Bridge::for_page(&canvas))
} else {
None
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FilmCraft in an
// `<iframe>`, opens files 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 FilmCraft in its own document and talks over DOM
// `CustomEvent`s on the canvas. In host mode the host sends the document to edit, so no demo
// project opens and nothing from an earlier visit is recovered.
let host = host::Bridge::connect(&canvas);
let in_host_mode = host.is_some();
let isolated = js_sys::Reflect::get(&js_sys::global(), &"crossOriginIsolated".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
+7 -15
View File
@@ -1,26 +1,18 @@
//! The page host mode's pure rules (the `data-host` opt-in, open names, project or media). They run
//! The page host mode's pure rules (open names, project or media). They run
//! natively even though the rest of the crate is wasm32-only.
#[path = "../../src/hostmode.rs"]
mod rules;
use rules::{DEFAULT_OPEN_NAME, MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok, page_host_requested};
#[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");
}
}
use rules::{DEFAULT_OPEN_NAME, MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok};
#[test]
fn open_names_fall_back_to_the_default_and_lose_path_separators() {
assert_eq!(open_name(Some("Trailer v2.fcproj")), "Trailer v2.fcproj");
assert_eq!(open_name(Some(" Sheet2/../../etc.fcproj ")), " Sheet2_.._.._etc.fcproj ");
assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);
assert_eq!(open_name(Some(" ")), DEFAULT_OPEN_NAME);
assert_eq!(open_name(None), DEFAULT_OPEN_NAME);
assert_eq!(open_name("Trailer v2.fcproj"), "Trailer v2.fcproj");
assert_eq!(open_name("Sheet2/../etc.fcproj"), "Sheet2_.._etc.fcproj");
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
assert_eq!(open_name(""), DEFAULT_OPEN_NAME);
assert_eq!(open_name(" "), DEFAULT_OPEN_NAME);
}
#[test]
+3 -1
View File
@@ -83,7 +83,7 @@ downloading them. The two sides talk `postMessage` with plain window messages th
| Message | Direction | Fields |
|---|---|---|
| `filmcraft:ready` | app → page | `version`: the app is running and listening; send a file now |
| `filmcraft:ready` | app → page | `version`, `appVersion`: the app is running and listening; send a file now |
| `filmcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): a `.fcproj` opens like File › Open, anything else imports like a file dropped on the page. Over 512 MiB or malformed: refused with a message in the app, nothing crashes |
| `filmcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download — Save, Save As, Save a Copy, video / frame / audio exports, captions, interchange (AAF, OMF, EDL, markers), extracted audio, the schema backup. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file — the project the next Save writes: `true` when the app adopts the file and the page didn't already have it as the document (Save As, and the first Save of a project the page never sent); `false` for a Save of the document under its own name and for every copy (Save a Copy, exports, the schema backup beside an upgraded project) |
| `filmcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
@@ -159,6 +159,8 @@ await start("filmcraft_canvas"); // the canvas id the app renders on
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 `filmcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. FilmCraft reads bytes, so a host sends `bytes`, not a `File`.
### Trust and limits
- **Who is trusted.** In page host mode the page and FilmCraft 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.