mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-10 04:19:33 -06:00
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
263 lines
12 KiB
Rust
263 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);
|
|
}
|
|
crate::api::install(&mut app, cc.egui_ctx.clone());
|
|
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",
|
|
}
|
|
}
|