Files
pdfcraft/apps/pdfcraft-web/src/main.rs
T
Helen Kwok a9e98e0db6 pdfcraft-web: window.pdfcraft, the page command API from craft-web-api, opt-in with ?control
?control attaches the UI control channel (the one --control serves) and installs window.pdfcraft: request, execute (ui.command), commands (ui.commands), docInspect (ui.state), uiInspect (ui.inspect) and screenshot. The channel keeps the accessibility tree of every frame, so it stays off unless the page asks. Opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:17:43 +08:00

242 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(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
fn main() {
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) = app_canvas() 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 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())
&& 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(),
);
}
// `?control`: `window.pdfcraft`, the page's command API (`api`). Opt-in: the control
// channel keeps the accessibility tree of every frame.
if query_param("control").is_some() {
api::install(app.attach_control(&cc.egui_ctx));
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
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 app's canvas: the one element `index.html` and every embedding page must provide, with
/// the id the app looks up.
#[cfg(target_arch = "wasm32")]
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
use eframe::wasm_bindgen::JsCast;
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
}
/// 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` 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 std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
/// The app id in the host messages (`pdfcraft:ready`).
const APP: &str = "pdfcraft";
/// 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;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
}
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 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", data.size(), MAX_OPEN_BYTES >> 20).into(),
);
return;
}
if let Ok(mut q) = inbox.lock() {
q.push((name, view.to_vec()));
}
ctx.request_repaint();
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web");
}