Files
deckcraft/apps/deckcraft-web/src/web.rs
T
Helen Kwok 9299113268 deckcraft-web: speak the host messages through craft-web-host, and add page host mode
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420). `data-host="page"` on the canvas now hosts DeckCraft in a page's own document, as in the other apps; the parent bridge keeps its behaviour.
2026-11-05 17:51:01 +08:00

262 lines
12 KiB
Rust

//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
use wasm_bindgen::JsCast as _;
const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"];
const PICTURE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"];
const AUDIO_EXTS: &[&str] = &["wav", "mp3", "m4a", "aac", "flac", "ogg", "opus", "aif", "aiff"];
const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
const CANVAS_ID: &str = "deckcraft_canvas";
const LOADING_ID: &str = "deckcraft_loading";
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
/// build the sample or blank deck, and replace the loading element when launch finishes.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
return;
};
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
// opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
// canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
// never the framed page.
let hosted = crate::presenter::token().is_none();
let bridge = if hosted { crate::host::Bridge::detect(&canvas) } else { None };
if hosted && bridge.is_none() && query_param("host").as_deref() == Some("parent") {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
}
let app_bridge = bridge.clone();
let mut options = eframe::WebOptions::default();
if query_has("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
if let Some(token) = crate::presenter::token() {
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
}
let inbox: Inbox = Inbox::default();
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, cc.egui_ctx.clone());
}
let mut app = SlideApp::new(Session::new(), services);
// In host mode the parent sends the presentation to edit; no sample meanwhile.
if app_bridge.is_some() {
if let Err(e) = app.start_for_host() {
log::error!("new presentation: {e}");
}
} else if !query_has("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample deck: {e}");
}
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
log::error!("new presentation: {e}");
}
if query_has("show") {
app.start_show(0, false);
}
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut app, &author);
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>DeckCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
}
}
});
}
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)
}
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
/// `?author=` is free text.
fn query_has(key: &str) -> bool {
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: SlideApp,
inbox: Inbox,
/// The parent page that keeps the files (`?host=parent`).
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
/// page sent first and telling it about unsaved changes after.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(bridge) = &self.bridge {
bridge.open_files(&mut self.app);
}
self.app.logic(ctx);
if let Some(bridge) = &self.bridge {
bridge.report_dirty(&self.app);
}
if self.app.show.is_none()
&& let Some(host) = self.app.services.presenter.as_mut()
{
host.close();
}
}
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
/// and deliver the id, name, and bytes through the inbox.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
// fresh input, and capture the document/slide before the asynchronous read finishes.
for f in std::mem::take(&mut raw.dropped_files) {
let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes }));
ctx.request_repaint();
}
Err(e) => {
log::error!("couldn't read dropped file {name}: {e}");
push_inbox(&inbox, InboxEvent::Cancelled { id });
ctx.request_repaint();
}
}
});
}
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
}
}
/// Web services: no file paths, so `pick_open`/`pick_save`/`read` stay `None` and the UI falls
/// back to `open_async` (browser picker → inbox) and `download`.
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
Err(error) => {
log::error!("Presenter window: {error}");
None
}
};
Services {
presenter,
open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
"picture" => rfd::AsyncFileDialog::new().add_filter("Pictures", PICTURE_EXTS),
"audio" => rfd::AsyncFileDialog::new().add_filter("Audio", AUDIO_EXTS),
"video" => rfd::AsyncFileDialog::new().add_filter("Video", VIDEO_EXTS),
_ => rfd::AsyncFileDialog::new().add_filter("Presentations", PRESENTATION_EXTS),
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
push_inbox(&inbox, InboxEvent::Cancelled { id: request });
ctx.request_repaint();
return;
};
let bytes = file.read().await;
push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes }));
ctx.request_repaint();
});
})),
write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),
download: Some(Box::new(|name: &str, bytes: &[u8]| {
if let Err(e) = download(name, bytes) {
log::error!("download of {name} failed: {e}");
}
})),
inbox: Some(inbox),
..Default::default()
}
}
fn push_inbox(inbox: &Inbox, event: InboxEvent) {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event);
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "deckcraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click has been dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("gif") => "image/gif",
Some("txt") => "text/plain",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
Some("deckcraft") => "application/zip",
_ => "application/octet-stream",
}
}