//! 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::().ok()) else { log::error!("missing "); return; }; // `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and // stores what it saves (`host`). The presenter popup is never the framed page. let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none(); let bridge = if host { crate::host::Bridge::new() } else { None }; if host && bridge.is_none() { 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); } 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_none() && !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!("

DeckCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), } } }); } fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } fn query_param(key: &str) -> Option { 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, } 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), 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", } }