Web: opt-in embedding bridge for host pages (?host=parent)

A page that frames GridCraft on the same origin can now open workbooks
in it and store what it saves, through postMessage, instead of Save and
Export becoming browser downloads. Built for a Nextcloud integration,
but nothing in it is host-specific.

Only the web shell changes:
- gridcraft:open from the parent goes through the inbox, like a picked
  or dropped file
- Services::download sends the bytes to the parent (gridcraft:save); an
  .xlsx workbook handed over counts as saved under that name
- gridcraft:dirty reports unsaved changes; gridcraft:ready and
  gridcraft:failed report start-up
- in host mode no sample opens, and ?author= names the user for notes
  and comments

Enabled only by ?host=parent inside a frame; only the parent window on
the page's own origin is heard or answered. Without it nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp
This commit is contained in:
Claude 2026-10-10 22:51:34 -04:00
parent fc45b7f6d8
commit 6e2caa9f86
No known key found for this signature in database
4 changed files with 202 additions and 5 deletions

View File

@ -26,4 +26,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", "Clipboard", "ClipboardItem", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }

View File

@ -0,0 +1,166 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//!
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
//! as PDF. The parent stores it and reports failures itself.
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `gridcraft: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 page
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
/// counts as saved under that name, as it would after Save on the desktop.
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
/// 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,
/// Names of the files handed to the parent since the last frame.
saved: Rc<RefCell<Vec<String>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
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()?;
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
}
/// Send saved and exported files to the parent instead of downloading them, and hear files
/// from it.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new notes and comments.
pub fn set_author(app: &mut SheetApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
}
/// After each frame: a workbook handed to the parent this frame counts as saved under the
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
pub fn sync(&self, app: &mut SheetApp) {
let saved = std::mem::take(&mut *self.saved.borrow_mut());
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
&& let Some(doc) = app.session.active_mut()
{
doc.saved = doc.wb.clone();
if doc.path.is_none() {
doc.title = name.clone();
}
}
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
}
fn post_save(&self, path: &str, bytes: &[u8]) -> 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", "gridcraft:save".into()), ("name", name.as_str().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:?}")))?;
self.saved.borrow_mut().push(name);
Ok(())
}
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("gridcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".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 is_workbook(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
}
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
}
}

View File

@ -8,9 +8,14 @@
//! (bytes arrive asynchronously through `Services::inbox`); Save downloads the file; dropped
//! files are read asynchronously.
//!
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample.
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;

View File

@ -25,6 +25,10 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
// workbooks in it and stores what it saves (`host`).
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let result = eframe::WebRunner::new()
.start(
canvas,
@ -32,13 +36,18 @@ pub fn start() {
Box::new(move |cc| {
let inbox: Inbox = Inbox::default();
let mut session = Session::new();
let q = query();
// In host mode the parent sends the workbook to edit; no sample meanwhile.
let q = if app_bridge.is_some() { String::new() } else { query() };
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
} else if q.contains("sample") {
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
}
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
@ -50,10 +59,19 @@ pub fn start() {
app.ui.language = browser;
}
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
Ok(Box::new(WebShell { app, inbox }))
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, bridge: app_bridge }))
}),
)
.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(),
@ -67,9 +85,14 @@ 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)
}
struct WebShell {
app: SheetApp,
inbox: Inbox,
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
@ -98,6 +121,9 @@ impl eframe::App for WebShell {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
if let Some(bridge) = &self.bridge {
bridge.sync(&mut self.app);
}
}
}