Web: a ?host=parent bridge for pages that keep WordCraft's files

A same-origin page that frames wordcraft-web (a Nextcloud app, a personal
file server) can now hand it a document and store what it saves, with the
same postMessage protocol as GridCraft's and PdfCraft's bridges:
wordcraft:ready / open / save {name, saveAs, bytes} / dirty / failed.

In host mode Save, Save As and Export go to the parent instead of the save
picker or a download (Services::host_save, with whether it was Save As).
Save writes the opened file under its own name and format, as the desktop
app does (an import-only .doc still becomes a .docx), and a Save or Save
As that keeps everything makes that name the document's file, so the next
Save goes to it. The parent guards leaving the page; ?author= names the
user for comments and tracked changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rob
2026-10-22 19:38:12 +01:00
co-authored by Claude Opus 5.5
parent 00bc6a4b02
commit caf5ea509c
5 changed files with 300 additions and 8 deletions
+1 -1
View File
@@ -29,4 +29,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
+158
View File
@@ -0,0 +1,158 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download: Save, Save As, File › Export and Export as
//! PDF. Save writes the opened file under its own name and format, as the desktop app does;
//! `saveAs` is true for Save As and File › Export. The parent stores it and reports failures
//! itself.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft: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. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), 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()?;
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
pub fn set_author(app: &mut WordApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.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("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.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();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
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()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
+5
View File
@@ -8,6 +8,9 @@
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
@@ -15,6 +18,8 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;
+27 -3
View File
@@ -27,9 +27,15 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
if bridge.is_none()
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
{
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -56,12 +62,17 @@ pub fn start() {
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
let sample = query().contains("sample");
// In host mode the parent sends the document to edit; no sample meanwhile.
let sample = app_bridge.is_none() && query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let messages = Rc::new(RefCell::new(Vec::new()));
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
@@ -79,10 +90,19 @@ pub fn start() {
if !sample {
app.start_screen();
}
if let Some(bridge) = &app_bridge {
if let Some(author) = query_param("author") {
crate::host::Bridge::set_author(&mut app, &author);
}
bridge.post_ready();
}
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
}),
)
.await;
if let (Err(e), Some(bridge)) = (&result, &bridge) {
bridge.post_failed(&format!("{e:?}"));
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -96,6 +116,10 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
+109 -4
View File
@@ -127,6 +127,12 @@ pub struct Services {
/// #1315). The hook starts a ticket for such a save and reports how it ended; until then the
/// document's save isn't confirmed, and a New/Open held back by the Save Changes prompt waits.
pub async_saves: Option<async_saves::AsyncSaves>,
/// Web, framed by a page that keeps the files (`?host=parent`): set alongside `download`,
/// Save, Save As and Export hand the bytes to that page instead, with whether it was Save As
/// ([`HostSaveFn`]). Save then writes the opened file under its own name and format, as on
/// the desktop, and a Save or Save As that keeps everything makes that name the document's
/// file, so the next Save goes to it.
pub host_save: Option<HostSaveFn>,
/// Web: told whether the document has unsaved changes after each pass, for the browser's
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
pub on_dirty: Option<Box<dyn Fn(bool)>>,
@@ -223,6 +229,10 @@ pub enum DefaultApp {
/// Files delivered asynchronously.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
/// the file, and the save failed.
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no
/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page
/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input"
@@ -1004,7 +1014,7 @@ impl WordApp {
}
// Web: saving and exporting become downloads.
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id));
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
// A save with a password encrypts this and later saves (`file.save`'s `password`).
if matches!(id, "file.save" | "file.saveAs")
@@ -1029,9 +1039,12 @@ impl WordApp {
if let Some(saves) = &saves {
let _ = saves.take_started();
}
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
let delivered = match (&self.services.host_save, &self.services.download) {
(Some(host), _) => host(&name, &bytes, id == "file.saveAs"),
(None, Some(download)) => download(&name, &bytes),
(None, None) => Ok(()),
};
if let Err(e) = delivered {
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &name), ("error", &e)]);
log::error!("download of {name} failed: {e}");
self.status(msg.clone());
@@ -1042,6 +1055,10 @@ impl WordApp {
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
if marked_saved {
self.session.dirty = false;
// The host page keeps the file under this name now, as a desktop save would.
if self.services.host_save.is_some() {
self.session.path = Some(std::path::PathBuf::from(&name));
}
}
// The browser's picker writes the file later (#1315): until it says how that went,
// the document's save isn't confirmed.
@@ -1931,6 +1948,20 @@ impl WordApp {
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx.
/// The name a browser Save or Export without a name uses. A host page that keeps the files
/// ([`Services::host_save`]) gets Save back under the opened file's own name and format, as
/// the desktop app writes it, except an import-only Word 97–2003 file (#2445).
fn web_save_name(&self, id: &str) -> String {
if id == "file.save"
&& self.services.host_save.is_some()
&& !self.legacy_word_path()
&& let Some(name) = self.session.path.as_ref().and_then(|p| p.file_name())
{
return name.to_string_lossy().to_string();
}
self.default_save_name()
}
fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
match ext {
@@ -6761,6 +6792,80 @@ mod tests {
}
}
/// Web inside a page that keeps the files: the app with `host_save` recording each file it
/// is handed (name, Save As, size).
fn host_app(fail: bool) -> (WordApp, std::rc::Rc<std::cell::RefCell<Vec<(String, bool, usize)>>>) {
let mut a = typed();
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = got.clone();
a.services.download = Some(Box::new(|_, _| Err("downloads go to the host page".into())));
a.services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
if fail {
return Err("the page is gone".into());
}
seen.borrow_mut().push((name.to_string(), save_as, bytes.len()));
Ok(())
}));
(a, got)
}
/// Open `name` as the host page sends it (bytes of the typed document in that format).
fn host_open(a: &mut WordApp, name: &str) {
let bytes = wordcraft_engine::io::save_bytes_with(name, &typed().session.doc, None, &Default::default()).unwrap();
a.execute("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})).unwrap();
a.run("text.insert", json!({"text": "Edited "})).unwrap();
assert!(a.session.dirty);
}
/// Save hands the opened file back under its own name and format; Save As says so and
/// becomes the file the next Save writes.
#[test]
fn host_saves_go_back_to_the_opened_file() {
let (mut a, got) = host_app(false);
host_open(&mut a, "report.odt");
a.execute("file.save", json!({})).unwrap();
assert!(!a.session.dirty);
a.run("text.insert", json!({"text": "More "})).unwrap();
a.execute("file.saveAs", json!({"path": "report copy.docx"})).unwrap();
assert!(!a.session.dirty);
a.run("text.insert", json!({"text": "Again "})).unwrap();
a.execute("file.save", json!({})).unwrap();
let got = got.borrow();
let calls: Vec<(&str, bool)> = got.iter().map(|(n, s, _)| (n.as_str(), *s)).collect();
assert_eq!(calls, [("report.odt", false), ("report copy.docx", true), ("report copy.docx", false)]);
assert!(got.iter().all(|(_, _, len)| *len > 0));
}
/// A Markdown file goes back as Markdown, which doesn't keep everything: the document stays
/// unsaved, as on the desktop.
#[test]
fn host_save_of_markdown_keeps_its_format_and_stays_unsaved() {
let (mut a, got) = host_app(false);
host_open(&mut a, "notes.md");
a.execute("file.save", json!({})).unwrap();
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("notes.md", false)));
assert!(a.session.dirty);
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("notes.md")));
}
/// An import-only Word 97–2003 file is saved as a .docx, never back over itself (#2445).
#[test]
fn host_save_of_a_legacy_word_file_writes_a_docx() {
let (mut a, got) = host_app(false);
a.session.path = Some("old.doc".into());
a.execute("file.save", json!({})).unwrap();
assert_eq!(got.borrow().first().map(|(n, _, _)| n.as_str()), Some("old.docx"));
}
/// The page not getting the file is a failed save: the document keeps its changes.
#[test]
fn a_host_save_that_fails_leaves_the_document_unsaved() {
let (mut a, _) = host_app(true);
host_open(&mut a, "report.docx");
assert!(a.execute("file.save", json!({})).is_err());
assert!(a.session.dirty);
}
/// Macro-enabled documents and templates keep everything (#172), so once saved in this
/// session AutoSave covers them like a .docx.
#[test]