Merge pull request #59 from storytold/pr/never-crash-ui

Never crash: ui-egui and the web app
This commit is contained in:
Brandon Thomas 2026-10-04 14:24:24 -04:00 committed by GitHub
commit ba5fff320b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 35 additions and 24 deletions

View File

@ -14,6 +14,7 @@
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
//! persistence and `?workers=0` renders on the main thread.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod bench;
pub mod files;

View File

@ -29,17 +29,18 @@ const LIBRARY_DIR: &str = "library";
/// How often view state, UI prefs and the thumbnail index are saved (ms).
const SAVE_EVERY_MS: f64 = 1000.0;
fn window() -> web_sys::Window {
web_sys::window().expect("no window")
/// The page's window (`None` only outside a browser page, e.g. in a worker).
fn window() -> Option<web_sys::Window> {
web_sys::window()
}
/// Milliseconds since navigation start.
fn perf_now() -> f64 {
window().performance().map(|p| p.now()).unwrap_or(0.0)
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
}
fn set_status(text: &str) {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
el.set_text_content(Some(text));
}
}
@ -59,7 +60,7 @@ struct Options {
impl Options {
fn from_url() -> Options {
let q = window().location().search().unwrap_or_default();
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let params: Vec<(String, String)> = q
.trim_start_matches('?')
.split('&')
@ -102,7 +103,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
/// Show the browser's open dialog; picked files are imported asynchronously.
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
let Some(doc) = window().document() else { return };
let Some(doc) = window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_multiple(true);
@ -131,7 +132,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
opts.set_type(crate::store::mime_for(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
let doc = window().document().ok_or("no document")?;
let doc = window().and_then(|w| w.document()).ok_or("no document")?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
a.set_href(&url);
a.set_download(name);
@ -140,7 +141,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
if let Some(w) = window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
}
log::info!("exported {name} ({} bytes)", bytes.len());
Ok(())
}
@ -315,7 +318,7 @@ impl WebApp {
}
app.ui = app.ui.sanitized();
let n = opts.workers.unwrap_or_else(|| {
let cores = window().navigator().hardware_concurrency() as usize;
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
cores.saturating_sub(1).clamp(1, 4)
});
let workers =
@ -516,8 +519,10 @@ pub fn start() {
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) =
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
let Some(canvas) = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
log::error!("missing <canvas id=\"lightcraft_canvas\">");
return;
@ -527,7 +532,7 @@ pub fn start() {
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
match r {
Ok(()) => {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
el.remove();
}
}

View File

@ -214,7 +214,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
};
let task = app.scan.take().expect("scan task");
let Some(task) = app.scan.take() else { return };
if task.progress.cancel.load(Ordering::Relaxed) {
return;
}
@ -333,7 +333,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
p["renameStart"] = json!(1 + task.imported);
}
let r = app.session.execute("library.import", &p);
let task = app.import.as_mut().expect("import task");
let Some(task) = app.import.as_mut() else { return };
task.done += n;
match r {
Ok(v) => {
@ -354,7 +354,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
if !task.queue.is_empty() {
return;
}
let task = app.import.take().expect("import task");
let Some(task) = app.import.take() else { return };
let steps = app.session.undo.len().saturating_sub(task.undo0);
let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
app.session.merge_undo(steps, &label);

View File

@ -4,6 +4,7 @@
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod control;
pub mod export_task;

View File

@ -124,12 +124,16 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b
let (tx, rx) = channel();
let (j, p, c) = (job.clone(), progress.clone(), cancel.clone());
let work = move || {
let r = j.run(&|f, stage| {
if let Ok(mut g) = p.lock() {
*g = (f, stage.to_string());
}
!c.load(Ordering::Relaxed)
});
let run = || {
j.run(&|f, stage| {
if let Ok(mut g) = p.lock() {
*g = (f, stage.to_string());
}
!c.load(Ordering::Relaxed)
})
};
// a panicking merge reports an error instead of leaving "Merging…" up forever
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).unwrap_or_else(|_| Err("the merge failed unexpectedly".into()));
let _ = tx.send(r);
};
#[cfg(not(target_arch = "wasm32"))]
@ -220,7 +224,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(t) = &app.merge.preview_task {
match t.rx.try_recv() {
Ok(Ok(out)) => {
let t = app.merge.preview_task.take().expect("task");
let Some(t) = app.merge.preview_task.take() else { return };
if let Some(img) = out.preview {
let color = Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()));
let tex = ctx.load_texture("merge-preview", color.clone(), egui::TextureOptions::LINEAR);
@ -229,7 +233,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
Ok(Err(e)) => {
let t = app.merge.preview_task.take().expect("task");
let Some(t) = app.merge.preview_task.take() else { return };
if e != "cancelled" {
app.merge.error = Some(e);
app.merge.failed_options = Some(t.options);
@ -242,7 +246,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(t) = &app.merge.final_task {
match t.rx.try_recv() {
Ok(r) => {
let t = app.merge.final_task.take().expect("task");
let Some(t) = app.merge.final_task.take() else { return };
let what = if t.options.command == "merge.panorama" {
"Panorama"
} else if t.options.command == "merge.hdrPanorama" {