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

A page that frames PdfCraft on the same origin can now pass it PDFs and
receive saves through postMessage, instead of Save becoming a browser
download. Built for a Nextcloud integration, but nothing in it is
host-specific.

- ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`),
  None by default. On wasm, Save hands the bytes to `host_save` when
  set; `host_dirty` hears each frame whether any tab has unsaved work.
- pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a
  frame. Messages: pdfcraft:ready / open / save / dirty / failed; only
  the parent window on our own origin is heard or answered.
  `?author=` names comment authors unless the user chose a name.
- Test: host_dirty_hears_when_unsaved_work_appears_and_goes.

Without `?host=parent` nothing changes.


Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 15:50:06 -04:00
1 parent 5ad2ecfef9
commit b34b3263d6
5 files changed
+211 -6

No files matched your search

+1 -1
View File
@@ -16,5 +16,5 @@ egui = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
js-sys = "0.3"
+148
View File
@@ -54,12 +54,34 @@ 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=parent`: a same-origin page embedding PdfCraft in an iframe (such
// as the Nextcloud app) passes files in and takes saves back (`host` below).
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
// `?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())
&& app.comment_prefs.author == "Guest"
{
app.comment_prefs.author = author.chars().take(200).collect();
}
bridge.listen(app.startup_inbox.clone(), cc.egui_ctx.clone());
app.host_save = Some(bridge.save_fn());
app.host_dirty = Some(bridge.dirty_fn());
bridge.post_ready();
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
}
}
});
}
@@ -97,6 +119,132 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
/// `type`:
///
/// - `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.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
#[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).
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 })
}
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
let sent = match transfer {
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
None => self.parent.post_message(message, &self.origin),
};
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
pub fn post_ready(&self) {
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
let _ = self.post(&m, None);
}
}
/// The app couldn't start (no WebGL, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
let _ = self.post(&m, None);
}
}
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::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("pdfcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
js_sys::Uint8Array::new(&bytes).to_vec()
} else {
return;
};
if let Ok(mut q) = inbox.lock() {
q.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();
}
}
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();
let m =
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
bridge.post(&m, 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;
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
let _ = bridge.post(&m, None);
}
}
})
}
}
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()
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web");
+21 -5
View File
@@ -404,7 +404,7 @@ impl PdfCraftApp {
}
#[cfg(target_arch = "wasm32")]
{
let _ = (target, path);
let _ = path;
if !self.flatten_fill_sign_for_save(id) {
return false;
}
@@ -415,16 +415,32 @@ impl PdfCraftApp {
return false;
}
};
match download(&name, &bytes) {
// An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise
// the browser downloads them.
let to_host = self.host_save.is_some();
let delivered = match &self.host_save {
Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)),
None => download(&name, &bytes),
};
match delivered {
Ok(()) => {
let _ = self.session.mark_saved(id, bytes, None);
if let Some(doc) = self.session.get(id) {
self.views[index].document_changed(&doc.info);
if let Some(doc) = self.session.get(id)
&& let Some(view) = self.views.get_mut(index)
{
view.document_changed(&doc.info);
}
// The host reports where the file went (and any failure) itself.
if !to_host {
self.notify_fmt("Downloaded {name}", &[("name", &name)]);
}
self.notify_fmt("Downloaded {name}", &[("name", &name)]);
after(self);
true
}
Err(e) if to_host => {
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]);
false
}
Err(e) => {
self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
false
+20
View File
@@ -341,6 +341,14 @@ pub enum OsEvent {
/// Returns the [`OsEvent`]s that arrived since it was last called (set by the desktop app).
pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
/// Hands a saved PDF to the page embedding PdfCraft (web): `(name, bytes, save_as)`. `save_as`
/// is `true` for Save As. Set by the web app when a host page (such as a Nextcloud app) asked
/// for saves; without it the browser downloads the file.
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
/// Told every frame whether any tab has unsaved work, so a host page can warn before leaving.
pub type HostDirtyFn = Box<dyn FnMut(bool)>;
pub struct PasswordPrompt {
pub name: String,
pub path: Option<String>,
@@ -435,6 +443,10 @@ pub struct PdfCraftApp {
pub unsaved_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Requests from the operating system, polled every frame (macOS Apple events).
pub os_events: Option<OsEventsFn>,
/// Save delivers to the embedding page instead of downloading (web, see [`HostSaveFn`]).
pub host_save: Option<HostSaveFn>,
/// Reports unsaved work to the embedding page (web, see [`HostDirtyFn`]).
pub host_dirty: Option<HostDirtyFn>,
/// A pending "save changes?" question (closing a dirty tab or quitting).
pub close_request: Option<CloseRequest>,
/// Save to this path instead of asking (tests and automation).
@@ -707,6 +719,8 @@ impl PdfCraftApp {
failed_inbox: Default::default(),
unsaved_flag: Default::default(),
os_events: None,
host_save: None,
host_dirty: None,
close_request: None,
save_override: None,
props_draft: None,
@@ -1895,6 +1909,12 @@ impl eframe::App for PdfCraftApp {
self.guard_quit(ctx);
let now = ctx.input(|i| i.time);
self.autosave_tick(now);
if self.host_dirty.is_some() {
let dirty = self.first_dirty().is_some();
if let Some(report) = self.host_dirty.as_mut() {
report(dirty);
}
}
self.poll_updates();
// Shortcuts deferred last frame: the text field has taken that frame's typing since.
let deferred = std::mem::take(&mut self.deferred_commands);
+21
View File
@@ -2186,3 +2186,24 @@ fn the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page()
assert_eq!(page_texts(h.state()), ["Page 1"]);
assert_eq!(h.state().views[0].current, 0);
}
#[test]
fn host_dirty_hears_when_unsaved_work_appears_and_goes() {
let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = reports.clone();
let mut h = harness(2, move |app| {
app.set_option("organize", "on").unwrap();
app.host_dirty = Some(Box::new(move |dirty| sink.lock().unwrap().push(dirty)));
});
assert_eq!(reports.lock().unwrap().last(), Some(&false), "a freshly opened document has nothing unsaved");
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
assert_eq!(reports.lock().unwrap().last(), Some(&true), "an edit is unsaved work");
let out = temp_path("host-dirty.pdf");
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(Modifiers::COMMAND, Key::S);
h.run_steps(3);
assert_eq!(reports.lock().unwrap().last(), Some(&false), "saving clears it");
let _ = std::fs::remove_file(out);
}