pdfcraft-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); PdfCraft keeps the save and dirty hooks, the open size limit and the author.
This commit is contained in:
Helen Kwok committed 2026-11-05 17:51:00 +08:00
1 parent c79e48d608
commit 3df903d516
4 files changed
+101 -241

No files matched your search

Generated
+10
View File
@@ -1303,6 +1303,15 @@ dependencies = [
"zip",
]
[[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-winprint-gdi"
version = "0.1.0"
@@ -4729,6 +4738,7 @@ dependencies = [
name = "pdfcraft-web"
version = "0.18.1"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"js-sys",
+2
View File
@@ -11,6 +11,8 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { 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" }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
+87 -241
View File
@@ -47,12 +47,11 @@ fn main() {
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
warn_before_unload(app.unsaved_flag.clone());
// Host modes (`host` below): `?host=parent`, a same-origin page embedding
// PdfCraft in an iframe (such as the Nextcloud app), or `data-host="page"`
// on the canvas, a host that runs the module in its own document.
if host_requested()
&& let Some(bridge) = host::Bridge::new()
{
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
// app), or `data-host="page"` on the canvas, a host that runs the module in
// its own document.
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
// `?author=`: the host's name for the user signs their comments,
// unless they chose a name in PdfCraft (the web default is "Guest").
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
@@ -75,9 +74,7 @@ fn main() {
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if host_requested()
&& let Some(bridge) = host::Bridge::new()
{
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
}
}
@@ -125,263 +122,112 @@ fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
}
/// Whether a host mode was asked for: `?host=parent` (the iframe bridge) or
/// `data-host="page"` on the canvas (the host document itself). Whether a bridge can actually
/// be built is [`host::Bridge::new`]'s answer.
#[cfg(target_arch = "wasm32")]
fn host_requested() -> bool {
query_param("host").as_deref() == Some("parent") || page_host_enabled(app_canvas().and_then(|c| c.get_attribute("data-host")).as_deref())
}
/// Host mode on the canvas is opt-in by attribute value: only the exact `page` turns it on
/// (`data-host="page"`). Everything else — `Page`, ` page`, `parent` (the URL opt-in), empty
/// or missing — keeps the normal app with downloads.
#[cfg(any(target_arch = "wasm32", test))]
fn page_host_enabled(data_host: Option<&str>) -> bool {
data_host == Some("page")
}
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. Both are
/// opt-in and carry the same messages; only the transport differs, so only how an event goes
/// out and how `open` comes in change.
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. 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 `pdfcraft:` prefix. This module is PdfCraft's part of it.
///
/// `?host=parent` — the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
/// iframe (such as the Nextcloud app). Messages are plain objects with a `type`, posted to the
/// parent window:
///
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
/// Only the parent window on the app's own origin is heard or answered. With a parent on
/// another origin there is no bridge: the app runs as if unframed and saves download.
///
/// `data-host="page"` on the app's canvas — for a host
/// that loads the wasm-bindgen module into its own document and renders on one of its own
/// canvases, with no iframe. The same messages travel as DOM `CustomEvent`s dispatched on that
/// canvas: the event name carries the `type`, and `detail` holds the same fields without it
/// (`pdfcraft:ready` carries `version`, `pdfcraft:save` carries `name`, `saveAs` and a `bytes`
/// `ArrayBuffer`). The host opens a file by dispatching a `pdfcraft:open` `CustomEvent` on the
/// canvas with `detail: { name, bytes }`. Events do not bubble, so nothing outside the canvas
/// hears them, and the canvas belongs to the document that loaded the module, so there is no
/// origin check. An unframed page that asks for `?host=parent` still gets no host mode: only
/// the exact attribute value `page` opts in.
/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the
/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas
/// is for a host that loads the module into its own document, with no iframe: the same messages
/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present.
/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it
/// says `file: false` and a host sends `bytes`.
/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a
/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied.
/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores
/// the file (and reports its own errors).
/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app
/// couldn't start.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
use std::rc::Rc;
use super::page_host_enabled;
use craft_web_host::web::{FileData, Host, Message};
#[derive(Clone)]
enum Transport {
/// A same-origin parent frame: send by `postMessage` with the origin, listen for
/// `message` events on the window.
Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },
/// The host document itself: send by dispatching a `CustomEvent` on the app's canvas,
/// and listen on it for `pdfcraft:open`.
Page { canvas: web_sys::HtmlCanvasElement },
}
/// The app id in the host messages (`pdfcraft:ready`).
const APP: &str = "pdfcraft";
#[derive(Clone)]
pub struct Bridge {
transport: Transport,
}
impl Bridge {
/// Picks the transport: `?host=parent` needs a framing page with a same-origin parent;
/// otherwise `data-host="page"` on the app's canvas opts the host document itself in. `None` when the parent is on another origin (or when
/// `?host=parent` is used unframed): messages only go to this origin, so the browser
/// would drop every save without an error while the document showed as saved (#2167).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let canvas = super::app_canvas();
if super::query_param("host").as_deref() != Some("parent") {
if page_host_enabled(canvas.as_ref().and_then(|c| c.get_attribute("data-host")).as_deref()) {
return Some(Self { transport: Transport::Page { canvas: canvas? } });
}
return None;
}
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;
}
Some(Self { transport: Transport::Parent { window, parent, origin } })
}
/// One outbound message, whatever the transport. `fields` is the payload: the parent
/// transport wraps it with a `type` in the posted object, the page transport puts it in
/// the event's `detail` as-is (the event name carries the type). `transfer` hands an
/// `ArrayBuffer` to the receiver instead of copying it (parent transport).
fn send(&self, name: &str, fields: &[(&str, JsValue)], transfer: Option<&JsValue>) -> Result<(), String> {
match &self.transport {
Transport::Parent { parent, origin, .. } => {
let mut all = Vec::with_capacity(fields.len().saturating_add(1));
all.push(("type", JsValue::from_str(name)));
all.extend_from_slice(fields);
let Some(m) = message(&all) else { return Err("couldn't build the message".to_string()) };
let sent = match transfer {
Some(t) => parent.post_message_with_transfer(&m, origin, &js_sys::Array::of1(t)),
None => parent.post_message(&m, origin),
};
sent.map_err(|e| js_error(&e))
}
Transport::Page { canvas } => {
let Some(detail) = message(fields) else { return Err("couldn't build the message".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 event = web_sys::CustomEvent::new_with_event_init_dict(name, &init).map_err(|e| js_error(&e))?;
// The bool says whether a listener called `preventDefault`; not a send failure.
canvas.dispatch_event(&event).map(|_| ()).map_err(|e| js_error(&e))?;
Ok(())
}
}
}
pub fn post_ready(&self) {
let _ = self.send("pdfcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);
}
/// The app couldn't start (no WebGL, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.send("pdfcraft:failed", &[("error", error.into())], None);
}
/// Hear `pdfcraft:open` — from the same-origin parent (a `message` on the window) or
/// from the host document (a `pdfcraft:open` on the canvas) — and queue the file like a
/// `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
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("pdfcraft:open") {
return;
}
queue_open(&data, &inbox, &ctx);
});
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();
}
}
Transport::Page { canvas } => {
let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };
queue_open(&open.detail(), &inbox, &ctx);
});
if canvas.add_event_listener_with_callback("pdfcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_open.forget();
}
}
}
}
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
let bridge = self.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
bridge.send("pdfcraft:save", &[("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())], Some(&buffer))
})
}
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
let bridge = self.clone();
let mut last = false;
Box::new(move |dirty: bool| {
if dirty != last {
last = dirty;
let _ = bridge.send("pdfcraft:dirty", &[("dirty", dirty.into())], None);
}
})
}
}
/// The name a file gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "document.pdf";
/// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits).
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// The longest file name taken from the host.
const MAX_NAME_CHARS: usize = 255;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
}
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or
/// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app.
fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
let name: String = string(data, "name")
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| "document.pdf".to_string())
.chars()
.take(MAX_NAME_CHARS)
.collect();
let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return };
let view = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.clone()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
js_sys::Uint8Array::new(&bytes)
} else {
impl Bridge {
/// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and
/// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent 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, #2167).
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGL, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
}
/// Hear `pdfcraft:open` and queue the file like a `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
queue_open(&name, &data, &inbox, &ctx);
}
});
}
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
let host = self.host.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as))
}
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
let host = self.host.clone();
// A host starts out clean: the first report is a change only when it is dirty.
let mut last = false;
Box::new(move |dirty: bool| {
if dirty != last {
last = dirty;
host.post_dirty(dirty);
}
})
}
}
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a
/// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255
/// characters); a blank one comes as `document` and becomes `document.pdf`.
fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(view) = data else {
eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into());
return;
};
// A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory.
if u64::from(view.length()) > MAX_OPEN_BYTES {
if data.size() > MAX_OPEN_BYTES as f64 {
eframe::web_sys::console::warn_1(
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", view.length(), MAX_OPEN_BYTES >> 20).into(),
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(),
);
return;
}
let bytes = view.to_vec();
if let Ok(mut q) = inbox.lock() {
q.push((name, bytes));
q.push((name, view.to_vec()));
}
ctx.request_repaint();
}
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()
}
fn js_error(value: &JsValue) -> String {
value.as_string().unwrap_or_else(|| format!("{value:?}"))
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web");
}
#[cfg(test)]
mod page_host_tests {
use super::page_host_enabled;
#[test]
fn only_the_exact_value_page_enables_the_page_host_mode() {
assert!(page_host_enabled(Some("page")));
for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] {
assert!(!page_host_enabled(off), "{off:?} must keep host mode off");
}
}
}
+2
View File
@@ -128,6 +128,8 @@ await init({ module_or_path: "./pdfcraft-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 `pdfcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. PdfCraft reads bytes, so a host sends `bytes`, not a `File`.
### Trust and limits
- **Who is trusted.** In page host mode the page and PdfCraft 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.