mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
With ?host=parent, saves are posted to the parent with the app's own origin as the target. A framing page on another origin never gets them: the browser drops each message without an error, post() returns Ok, and the document is marked saved and the leave-page warning cleared (#2167). Bridge::new now returns None unless the parent's origin is the app's own (reading another origin's location throws, which is None too). The app then runs as if unframed and saves download, and a console warning says why. Same-origin embedding is unchanged.
263 lines
12 KiB
Rust
263 lines
12 KiB
Rust
//! PdfCraft in the browser. Build: `cd apps/pdfcraft-web && trunk build --release`
|
|
//! (trunk generates the JS loader; no handwritten JS — plan/execution-plan.md §1).
|
|
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn main() {
|
|
use eframe::wasm_bindgen::JsCast;
|
|
use pdfcraft_ui_egui::PdfCraftApp;
|
|
|
|
let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() };
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
let Some(canvas) = web_sys::window()
|
|
.and_then(|w| w.document())
|
|
.and_then(|d| d.get_element_by_id("pdfcraft"))
|
|
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
|
else {
|
|
return;
|
|
};
|
|
let started = eframe::WebRunner::new()
|
|
.start(
|
|
canvas,
|
|
options,
|
|
Box::new(|cc| {
|
|
let mut app = PdfCraftApp::new();
|
|
// "printcraft": settings saved under the app's former name, PrintCraft.
|
|
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string("printcraft"))) {
|
|
app.restore(&json);
|
|
}
|
|
// `?file=<url>` opens a PDF from a URL (same-origin or CORS-enabled).
|
|
if let Some(url) = query_param("file") {
|
|
let inbox = app.startup_inbox.clone();
|
|
let failed = app.failed_inbox.clone();
|
|
let ctx = cc.egui_ctx.clone();
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
let name = url.rsplit('/').next().unwrap_or("document.pdf").split('?').next().unwrap_or("document.pdf").to_string();
|
|
match fetch_bytes(&url).await {
|
|
Ok(bytes) => {
|
|
if let Ok(mut q) = inbox.lock() {
|
|
q.push((name, bytes));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eframe::web_sys::console::error_1(&format!("PdfCraft: could not fetch {url}: {e}").into());
|
|
// Shown in the app too, not only in the console (#173).
|
|
if let Ok(mut q) = failed.lock() {
|
|
q.push((name, e));
|
|
}
|
|
}
|
|
}
|
|
ctx.request_repaint();
|
|
});
|
|
}
|
|
// 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();
|
|
} else if query_param("host").as_deref() == Some("parent") {
|
|
eframe::web_sys::console::warn_1(
|
|
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
|
|
);
|
|
}
|
|
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:?}")));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn warn_before_unload(unsaved: std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
|
use eframe::wasm_bindgen::{JsCast, closure::Closure};
|
|
let Some(window) = web_sys::window() else { return };
|
|
let handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
|
|
if unsaved.load(std::sync::atomic::Ordering::Relaxed) {
|
|
event.prevent_default();
|
|
}
|
|
});
|
|
if window.add_event_listener_with_callback("beforeunload", handler.as_ref().unchecked_ref()).is_ok() {
|
|
handler.forget();
|
|
}
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn query_param(key: &str) -> Option<String> {
|
|
let search = web_sys::window()?.location().search().ok()?;
|
|
web_sys::UrlSearchParams::new_with_str(&search).ok()?.get(key)
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
|
|
use eframe::wasm_bindgen::JsCast;
|
|
let window = web_sys::window().ok_or("no window")?;
|
|
let resp = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
|
|
let resp: web_sys::Response = resp.dyn_into().map_err(|_| "not a response")?;
|
|
if !resp.ok() {
|
|
return Err(format!("HTTP {}", resp.status()));
|
|
}
|
|
let buf = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
|
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. With a parent on
|
|
/// another origin there is no bridge: the app runs as if unframed and saves download.
|
|
#[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), 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 (#2167).
|
|
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 })
|
|
}
|
|
|
|
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");
|
|
}
|