diff --git a/src/app/mod.rs b/src/app/mod.rs index 788b49bd4..2205804b4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -927,6 +927,16 @@ pub(super) struct OpenCADStudio { /// prompt still has to offer, one at a time. #[cfg(target_arch = "wasm32")] web_autosave_pending: Vec, + /// The snapshot whose recovered open is queued: `(autosave key, + /// open name)`. The OPFS copy is the only one, so it is deleted only + /// once an open with exactly that name succeeds. + #[cfg(target_arch = "wasm32")] + web_autosave_consumed: Option<(String, String)>, + /// Autosave keys a web save wanted to remove while a snapshot round was + /// still writing (the round's late write would resurrect the snapshot + /// with a fresh mtime); the removes run when the round finishes. + #[cfg(target_arch = "wasm32")] + web_autosave_clear_after: Vec, // ── Floating panel windows ──────────────────────────────────────────── /// Active `iced_aw` colour picker: destination plus its initial colour. color_pick_target: Option<(ColorPickTarget, AcadColor)>, @@ -2324,8 +2334,8 @@ pub enum Message { /// Web: delete the offered autosave snapshot (and offer the next, if any). #[cfg(target_arch = "wasm32")] WebAutosaveDiscard, - /// Web: the offered snapshot's bytes are in (and the OPFS copy consumed); - /// open them as `name`. + /// Web: the offered snapshot's bytes are in; queue them for the guarded + /// open poll. The OPFS copy is consumed only once that open succeeds. #[cfg(target_arch = "wasm32")] WebAutosaveBytes(String, Result, String>), /// Web: one autosave round finished for all dirty tabs (serially). @@ -4410,6 +4420,10 @@ impl OpenCADStudio { web_autosave_warned: false, #[cfg(target_arch = "wasm32")] web_autosave_pending: Vec::new(), + #[cfg(target_arch = "wasm32")] + web_autosave_consumed: None, + #[cfg(target_arch = "wasm32")] + web_autosave_clear_after: Vec::new(), color_pick_target: None, color_picker_tab: ColorPickerTab::Index, recent_colors: Vec::new(), diff --git a/src/app/startup.rs b/src/app/startup.rs index 7ed6111f2..3a8b12cc6 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -26,14 +26,21 @@ impl OpenCADStudio { /// Queue the web crash-recovery prompt behind whatever startup modal is /// already showing (donation, GPU warning…); the after-message hook in - /// `update` surfaces it as soon as the slot is free. + /// `update` surfaces it as soon as the slot is free. No-op when nothing + /// is pending: once the autosave OPFS directory merely exists (even with + /// no entries), queueing unconditionally would show an empty + /// "Recover Drawing" modal on every boot. #[cfg(target_arch = "wasm32")] pub(super) fn queue_autosave_restore_prompt(&mut self) { + if self.web_autosave_pending.is_empty() { + return; + } if !self .pending_startup_modals .contains(&ModalKind::AutosaveRestore) { - self.pending_startup_modals.push_back(ModalKind::AutosaveRestore); + self.pending_startup_modals + .push_back(ModalKind::AutosaveRestore); } self.show_next_startup_modal(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7d0ed4c60..5a9fea58a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2525,22 +2525,33 @@ impl OpenCADStudio { ); // A clean save supersedes any crash-recovery snapshot for this // drawing — drop the old key and the new one so stale copies - // don't resurface in the next boot scan. + // don't resurface in the next boot scan. But an autosave + // round may still be writing the same key: its write would + // land AFTER the remove and resurrect the snapshot (with a + // newer mtime, so the next scan would offer it as + // recoverable). Defer the removes to the end of the round. let new_key = self.tabs[i] .current_path .as_ref() .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - recent_task = Task::batch([ - recent_task, - Task::perform( - async move { - let _ = crate::io::web_autosave::remove(&autosave_key).await; - let _ = crate::io::web_autosave::remove(&new_key).await; - }, - |_| Message::Noop, - ), - ]); + if self.web_autosave_in_flight { + self.web_autosave_clear_after.push(autosave_key); + if !new_key.is_empty() { + self.web_autosave_clear_after.push(new_key); + } + } else { + recent_task = Task::batch([ + recent_task, + Task::perform( + async move { + let _ = crate::io::web_autosave::remove(&autosave_key).await; + let _ = crate::io::web_autosave::remove(&new_key).await; + }, + |_| Message::Noop, + ), + ]); + } self.command_line .push_output(crate::tf!("Saved: {filename}").as_ref()); true @@ -3608,10 +3619,21 @@ impl OpenCADStudio { } /// One web autosave round finished. Silent on success; on failure one - /// command-line warning until a round succeeds again. + /// command-line warning until a round succeeds again. Also the drain + /// point for snapshot removes a save deferred while the round was + /// writing: the round's writes are done, so the keys can be cleared + /// for real now (no late write can resurrect them). #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task { self.web_autosave_in_flight = false; + let deferred = std::mem::take(&mut self.web_autosave_clear_after); + if !deferred.is_empty() { + wasm_bindgen_futures::spawn_local(async move { + for key in &deferred { + let _ = crate::io::web_autosave::remove(key).await; + } + }); + } match result { Ok(()) => self.web_autosave_warned = false, Err(error) => { @@ -3627,7 +3649,10 @@ impl OpenCADStudio { } /// The boot scan decided which snapshots are recoverable; queue the - /// restore prompt (behind any already-queued startup modal). + /// restore prompt (behind any already-queued startup modal). An empty + /// scan — a fresh profile, or everything already cleaned up — must not + /// surface an empty "Recover Drawing" modal, so the prompt is only + /// queued when there is something to offer. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosaves_scanned( &mut self, @@ -3643,8 +3668,12 @@ impl OpenCADStudio { Task::none() } - /// Restore the offered snapshot: read (and consume) the OPFS copy, then - /// open it as a recovered tab through the standard web-bytes open flow. + /// Restore the offered snapshot: read the OPFS copy — leaving it in + /// place — and hand the bytes to the 50 ms poll's guarded open queue. + /// The snapshot is the only copy, so it is deleted only once the + /// recovered drawing actually opens (`web_autosave_opened`, on the + /// matching `WebFileOpened` success); a parse failure or cancel leaves + /// it recoverable for the next boot. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_restore(&mut self) -> Task { let Some(entry) = self.web_autosave_pending.first().cloned() else { @@ -3655,18 +3684,37 @@ impl OpenCADStudio { self.close_active_modal(); let open_name = crate::io::web_autosave::recovered_name(&entry.name); let key = entry.name; + // Remember the pair: `web_autosave_opened` deletes the OPFS copy + // only when an open with exactly `open_name` succeeds. A failed + // read below clears the marker again (`on_web_autosave_bytes`). + self.web_autosave_consumed = Some((key.clone(), open_name.clone())); Task::perform( - async move { - let bytes = crate::io::web_autosave::read(&key).await?; - // The snapshot is spent: the recovered tab (and its recent - // copy) replaces it from here on. - let _ = crate::io::web_autosave::remove(&key).await; - Ok(bytes) - }, + async move { crate::io::web_autosave::read(&key).await }, move |result| Message::WebAutosaveBytes(open_name, result), ) } + /// The recovered drawing opened: its autosave snapshot — held back until + /// now — can finally be deleted, and the next queued snapshot can be + /// offered. A different name finishing first just means the recovered + /// open is still queued behind it; keep waiting. + #[cfg(target_arch = "wasm32")] + pub(super) fn web_autosave_opened(&mut self, open_name: &str) { + let Some((key, recorded)) = self.web_autosave_consumed.clone() else { + return; + }; + if recorded != open_name { + return; + } + self.web_autosave_consumed = None; + self.command_line + .push_output(crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref()); + wasm_bindgen_futures::spawn_local(async move { + let _ = crate::io::web_autosave::remove(&key).await; + }); + self.queue_next_web_autosave_prompt(); + } + /// Discard the offered snapshot; the prompt stays up for the next one. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_discard(&mut self) -> Task { @@ -3682,8 +3730,13 @@ impl OpenCADStudio { Task::none() } - /// The offered snapshot's bytes arrived (or did not): open the recovered - /// tab, or warn and move on to the next snapshot. + /// The offered snapshot's bytes arrived (or did not). On success the + /// bytes ride the 50 ms poll's guarded open queue — the poll starts an + /// open only when the slot is free, so the recovered drawing can + /// neither clobber an open in flight nor be lost to one, and the + /// snapshot survives until that open lands (`web_autosave_opened`). + /// On failure the snapshot stays in OPFS (discardable on a later boot) + /// and the prompt moves on to the next one. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_bytes( &mut self, @@ -3692,31 +3745,33 @@ impl OpenCADStudio { ) -> Task { match result { Ok(bytes) => { - self.command_line.push_output( - crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref(), - ); - let task = self.update(Message::WebFileDropped(open_name, bytes)); - // Further snapshots wait behind this one; queue the prompt - // again now that an open is in flight, so it reappears when - // the recovered drawing finishes opening. - if !self.web_autosave_pending.is_empty() - && !self - .pending_startup_modals - .contains(&crate::app::ModalKind::AutosaveRestore) - { - self.pending_startup_modals - .push_back(crate::app::ModalKind::AutosaveRestore); - } - task - } - Err(error) => { - self.command_line - .push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref()); - if !self.web_autosave_pending.is_empty() { - self.queue_autosave_restore_prompt(); - } + // The next prompt is queued only when this open settles + // (`web_autosave_opened` / the failure hook), not here — the + // poll has not started the open yet, so queueing now would + // show the modal before the recovered drawing loads. + crate::io::web_drop::enqueue_open(open_name, bytes); Task::none() } + Err(error) => { + // Unreadable snapshot: warn, but neither delete it nor + // re-offer it this session — it was already taken out of + // `web_autosave_pending`, so the cycle cannot loop on it. + // It stays discardable on the next boot. + self.web_autosave_consumed = None; + self.command_line + .push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref()); + self.queue_next_web_autosave_prompt(); + Task::none() + } + } + } + + /// Re-offer the remaining boot snapshots once the current one settled. + /// No-op when none are left (or the modal is already queued). + #[cfg(target_arch = "wasm32")] + pub(super) fn queue_next_web_autosave_prompt(&mut self) { + if !self.web_autosave_pending.is_empty() { + self.queue_autosave_restore_prompt(); } } @@ -4625,8 +4680,18 @@ impl OpenCADStudio { let width = ((max.x - min.x) as f64 * margin).max(1.0); let height = ((max.y - min.y) as f64 * margin).max(1.0); if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM { + // Per-axis: a thin-but-long profile keeps its long + // dimension instead of being clipped to the sheet, and + // the content is centered on the enlarged page. let (w, h) = standard_sheet.unwrap_or((297.0, 210.0)); - (w, h, -min.x as f64, -min.y as f64) + let page_w = width.max(w); + let page_h = height.max(h); + ( + page_w, + page_h, + (page_w - width) * 0.5 - min.x as f64, + (page_h - height) * 0.5 - min.y as f64, + ) } else { let pad_x = (width - (max.x - min.x) as f64) * 0.5; let pad_y = (height - (max.y - min.y) as f64) * 0.5; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b36abaeaf..7d7ac241d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1101,6 +1101,13 @@ impl OpenCADStudio { self.command_line .push_info(crate::tf!("Open cancelled: \"{}\"", p.name).as_ref()); } + // A cancelled recovered open keeps its snapshot (re-offered + // next boot — it is the only copy); the remaining queued + // snapshots can still be offered this session. + #[cfg(target_arch = "wasm32")] + if self.web_autosave_consumed.take().is_some() { + self.queue_next_web_autosave_prompt(); + } self.drain_pending_open() } @@ -1108,7 +1115,13 @@ impl OpenCADStudio { Message::WebFileDropped(name, bytes) => { // Bytes are already in hand (no picker round-trip), so this is // `on_open_file`'s web branch with the real name and size on - // the overlay from the start. + // the overlay from the start. An open already in flight must + // not be clobbered (its bytes are gone once dequeued), so the + // pair goes back to the queue for the next poll tick. + if self.opening.is_some() { + crate::io::web_drop::enqueue_open(name, bytes); + return Task::none(); + } let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); @@ -1135,6 +1148,22 @@ impl OpenCADStudio { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } + // A crash-recovery open settling here: success deletes its + // OPFS snapshot (the only copy, kept until now); a parse + // failure keeps it for the next boot. Either way the next + // queued snapshot can be offered again. + if self + .web_autosave_consumed + .as_ref() + .is_some_and(|(_, recorded)| *recorded == outcome.name) + { + if outcome.result.is_ok() { + self.web_autosave_opened(&outcome.name); + } else { + self.web_autosave_consumed = None; + self.queue_next_web_autosave_prompt(); + } + } if let Some(opening) = self.opening.as_mut() { opening.name = outcome.name.clone(); opening.source_path = Some(std::path::PathBuf::from(&outcome.name)); diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs index a5ed5deec..961304a9f 100644 --- a/src/io/web_autosave.rs +++ b/src/io/web_autosave.rs @@ -8,11 +8,18 @@ //! offered for restore at boot when newer than the drawing's last explicit //! save (the OPFS recent copy of the same name). //! +//! Losing that comparison never deletes a snapshot: the recent copy is +//! refreshed by every OPEN as well as every save, so a snapshot that just +//! went stale may still be the only survivor of a crash right after a +//! re-open. Accumulation is bounded instead — the boot scan keeps the +//! newest [`SNAPSHOT_CAP`] snapshots and evicts only the oldest ones beyond +//! that cap. +//! //! OPFS entry names carry only the FNV hash of the drawing name (like //! `web_recent`), so a small JSON index maps hashes back to names. The index //! is written before its snapshot: a crash between the two writes leaves a -//! name without bytes, which `list` filters out, rather than an orphan -//! snapshot nobody can name. +//! name without bytes — a missing file or a zero-byte one — which `list` +//! filters out, rather than an orphan snapshot nobody can name. #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; @@ -38,6 +45,9 @@ pub struct WebAutosaveEntry { /// A snapshot is worth offering when it is strictly newer than the last /// explicit save, or when no saved copy exists at all (the drawing was never /// saved — or its recent copy was evicted — so the snapshot is all there is). +/// The comparison decides *offering* only, never survival: the recent copy +/// is refreshed by every open, so "older than the recent copy" is not proof +/// that a snapshot recovers nothing. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option) -> bool { match saved_ms { @@ -46,6 +56,33 @@ pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option) -> bool } } +/// Upper bound on stored snapshots; the boot scan evicts the oldest ones +/// beyond it. Without a bound, snapshots superseded by a save (or by a +/// mere re-open) would pile up forever, since age no longer deletes them. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) const SNAPSHOT_CAP: usize = 16; + +/// Split `entries` into `(kept, evicted)` so at most `cap` snapshots remain, +/// both sides ordered by `modified_ms` (newest first within each side; the +/// evicted names overall run oldest-first, so an interrupted scan deletes +/// the least valuable snapshots first). This is the only deletion criterion +/// the boot scan applies — being older than the recent copy is not one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) fn cap_evictions( + mut entries: Vec, + cap: usize, +) -> (Vec, Vec) { + entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms)); + let overflow = entries.len().saturating_sub(cap); + let mut evicted: Vec = entries + .split_off(entries.len() - overflow) + .into_iter() + .map(|entry| entry.name) + .collect(); + evicted.reverse(); + (entries, evicted) +} + /// The name a restored drawing carries as a tab and download: the drawing's /// stem plus " (recovered)" and its own extension (DWG when the stored name /// has none, matching how autosave serializes untitled tabs). The extension @@ -89,7 +126,10 @@ pub async fn snapshot(name: &str, bytes: &[u8], thumbnail: Option<&[u8]>) -> Res } /// All live snapshots. Index rows whose file is gone (crash between writes, -/// partial eviction) are skipped rather than reported. +/// partial eviction) and zero-byte snapshots (the crash window after the +/// index write but before the snapshot write) are skipped rather than +/// reported — an empty file recovers nothing and would only route the user +/// into the corrupt-file recovery flow. #[cfg(target_arch = "wasm32")] pub async fn list() -> Result, String> { let directory = autosave_directory(false).await?; @@ -98,6 +138,9 @@ pub async fn list() -> Result, String> { let Ok(file) = super::web_recent::get_file(&directory, &snapshot_key(&name)).await else { continue; }; + if file.size() == 0.0 { + continue; + } entries.push(WebAutosaveEntry { name, size: file.size() as u64, @@ -130,22 +173,26 @@ pub async fn remove(name: &str) -> Result<(), String> { drawing.map(|_| ()) } -/// Boot scan: keep the snapshots worth offering (newer than the drawing's -/// last explicit save, newest first) and silently delete the stale ones — a -/// snapshot older than the saved copy recovers nothing and would otherwise -/// pile up across sessions. +/// Boot scan: offer the snapshots worth recovering — strictly newer than the +/// drawing's last explicit copy, newest first. Age never deletes: the recent +/// copy is refreshed by every OPEN as well as every save, so a snapshot that +/// lost the comparison may still be the only survivor of a crash right after +/// a re-open. Accumulation is bounded instead — beyond [`SNAPSHOT_CAP`] +/// snapshots the oldest are evicted, which is the only deletion here. #[cfg(target_arch = "wasm32")] pub async fn scan() -> Result, String> { + let (kept, evicted) = cap_evictions(list().await?, SNAPSHOT_CAP); + for name in evicted { + let _ = remove(&name).await; + } let mut recoverable = Vec::new(); - for entry in list().await? { + for entry in kept { let saved_ms = super::web_recent::modified_ms(&entry.name).await; if newer_than_saved(entry.modified_ms, saved_ms) { recoverable.push(entry); - } else { - let _ = remove(&entry.name).await; } } - recoverable.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms)); + // `cap_evictions` already ordered the survivors newest-first. Ok(recoverable) } @@ -219,4 +266,44 @@ mod tests { assert_eq!(recovered_name("a.b.dwg"), "a.b (recovered).dwg"); assert_eq!(recovered_name(""), " (recovered).dwg"); } + + #[test] + fn under_the_cap_nothing_is_evicted_and_order_is_newest_first() { + let (kept, evicted) = cap_evictions( + vec![ + entry("old.dwg", 100.0), + entry("new.dwg", 300.0), + entry("mid.dwg", 200.0), + ], + SNAPSHOT_CAP, + ); + assert!(evicted.is_empty()); + assert_eq!( + kept.iter().map(|e| e.name.as_str()).collect::>(), + ["new.dwg", "mid.dwg", "old.dwg"] + ); + } + + #[test] + fn over_the_cap_exactly_the_oldest_overflow_is_evicted() { + let entries = (0..SNAPSHOT_CAP + 4) + .map(|i| entry(&format!("d{i}.dwg"), i as f64)) + .collect(); + let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP); + assert_eq!(kept.len(), SNAPSHOT_CAP); + // Newest kept first, oldest evicted first. + assert_eq!(kept.first().map(|e| e.name.as_str()), Some("d19.dwg")); + assert_eq!( + evicted.iter().map(|name| name.as_str()).collect::>(), + ["d0.dwg", "d1.dwg", "d2.dwg", "d3.dwg"] + ); + } + + fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry { + WebAutosaveEntry { + name: name.to_string(), + size: 16, + modified_ms, + } + } } diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs index 92d031e8c..0bf027acf 100644 --- a/src/io/web_drop.rs +++ b/src/io/web_drop.rs @@ -5,7 +5,8 @@ //! queues them for the 50 ms control poll to open through the web-picker flow. use std::collections::VecDeque; -use std::sync::{Mutex, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; @@ -14,14 +15,25 @@ use wasm_bindgen_futures::JsFuture; const QUEUE_CAP: usize = 8; static DROPPED: Mutex)>> = Mutex::new(VecDeque::new()); -static INSTALLED: OnceLock<()> = OnceLock::new(); +// `OnceLock::take` needs `&mut self`, which an immutable static cannot +// provide — an AtomicBool swap gives the same set-once marker and can still +// be reverted when no document exists. +static INSTALLED: AtomicBool = AtomicBool::new(false); fn warn(line: &str) { web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line)); } +fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque<(String, Vec)>> { + // The critical sections are push/pop only, so poisoning is unreachable; + // recover anyway rather than panic across the JS boundary. + DROPPED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + fn enqueue(name: String, bytes: Vec) { - let mut queue = DROPPED.lock().unwrap(); + let mut queue = lock_queue(); if queue.len() >= QUEUE_CAP { drop(queue); warn(&format!( @@ -32,6 +44,12 @@ fn enqueue(name: String, bytes: Vec) { queue.push_back((name, bytes)); } +/// Queue already-read drawing bytes (drag-and-drop, autosave restore) for the +/// 50 ms poll to open through the guarded web-picker flow. +pub fn enqueue_open(name: String, bytes: Vec) { + enqueue(name, bytes); +} + /// JS-callable entry (same shape as `ocs_control_submit`) for hosts that /// forward drops themselves. #[wasm_bindgen::prelude::wasm_bindgen] @@ -41,7 +59,7 @@ pub fn ocs_web_drop_file(name: String, bytes: js_sys::Uint8Array) { /// Pop the oldest dropped file that the poll has not started opening yet. pub fn take_dropped() -> Option<(String, Vec)> { - DROPPED.lock().unwrap().pop_front() + lock_queue().pop_front() } /// Feed already-read bytes through the same loader tail the web picker uses @@ -71,35 +89,44 @@ pub async fn open_dropped( } } -/// Register the document-level `dragover`/`drop` listeners once. `dragover` -/// MUST preventDefault or the browser navigates to the dropped file instead -/// of delivering it to the page. +/// Register the document-level `dragenter`/`dragover`/`drop` listeners once. +/// `dragover` MUST preventDefault or the browser navigates to the dropped file +/// instead of delivering it to the page. pub fn install() { - if INSTALLED.set(()).is_err() { + if INSTALLED.swap(true, Ordering::SeqCst) { return; } let Some(document) = web_sys::window().and_then(|window| window.document()) else { + // Only mark installed once the listeners really are: an early + // no-document context must not permanently disable drops. + INSTALLED.store(false, Ordering::SeqCst); return; }; - let on_dragover = wasm_bindgen::closure::Closure::::new( + let cancel = wasm_bindgen::closure::Closure::::new( move |event: web_sys::Event| { event.prevent_default(); }, ); - let _ = - document.add_event_listener_with_callback("dragover", on_dragover.as_ref().unchecked_ref()); - on_dragover.forget(); + // Canceling `dragenter` too is belt-and-braces some engines still want + // before treating the page as a drop target. + let _ = document.add_event_listener_with_callback("dragenter", cancel.as_ref().unchecked_ref()); + let _ = document.add_event_listener_with_callback("dragover", cancel.as_ref().unchecked_ref()); + cancel.forget(); let on_drop = wasm_bindgen::closure::Closure::::new( move |event: web_sys::Event| { event.prevent_default(); - event.stop_propagation(); let Some(drag) = event.dyn_ref::() else { return; }; let Some(files) = drag.data_transfer().and_then(|transfer| transfer.files()) else { return; }; + // Start every read here, inside the handler, and finish them in + // one task in drop order: legacy WebKit dislikes drag-originated + // reads that begin after the event returns, and parallel reads + // would enqueue (and open) in completion order, not drop order. + let mut reads: Vec<(String, js_sys::Promise)> = Vec::new(); for index in 0..files.length() { let Some(file) = files.get(index) else { continue; @@ -113,15 +140,27 @@ pub fn install() { )); continue; } - wasm_bindgen_futures::spawn_local(async move { - match read_file(&file).await { - Ok(bytes) => enqueue(name, bytes), - Err(error) => web_sys::console::error_1(&wasm_bindgen::JsValue::from_str( - &format!("ocs: failed to read dropped file \"{name}\": {error}"), - )), - } - }); + reads.push((name, file.array_buffer())); } + if reads.is_empty() { + return; + } + // The page is the only intended drop target; hide drawing drops + // from host-page handlers only once there is something to open. + event.stop_propagation(); + wasm_bindgen_futures::spawn_local(async move { + for (name, read) in reads { + match JsFuture::from(read).await { + Ok(buffer) => enqueue(name, js_sys::Uint8Array::new(&buffer).to_vec()), + Err(error) => { + web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!( + "ocs: failed to read dropped file \"{name}\": {}", + error.as_string().unwrap_or_else(|| format!("{error:?}")) + ))) + } + } + } + }); }, ); let _ = document.add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref()); @@ -133,11 +172,3 @@ fn is_drawing_name(name: &str) -> bool { matches!(extension.to_ascii_lowercase().as_str(), "dwg" | "dxf") }) } - -/// Same Blob → bytes read as the OPFS recent copies (`web_recent`). -async fn read_file(file: &web_sys::File) -> Result, String> { - let buffer = JsFuture::from(file.array_buffer()) - .await - .map_err(|error| error.as_string().unwrap_or_else(|| format!("{error:?}")))?; - Ok(js_sys::Uint8Array::new(&buffer).to_vec()) -} diff --git a/src/io/web_recent.rs b/src/io/web_recent.rs index 7fb2291d3..ffa93bef1 100644 --- a/src/io/web_recent.rs +++ b/src/io/web_recent.rs @@ -127,6 +127,24 @@ pub(crate) async fn write_entry( .map_err(js_error)? .dyn_into::() .map_err(js_error)?; + if let Err(error) = write_and_close(&writable, bytes).await { + // A failed write must still release the stream: the + // FileSystemWritableFileStream holds an exclusive lock on the entry, + // and leaving it undrained makes every later write to the same file + // fail the same way — one quota error would freeze autosave (its + // round would never finish) for the rest of the session. `abort` + // discards the pending bytes; ignore its own failure, there is + // nothing better to report than `error`. + let _ = JsFuture::from(writable.abort()).await; + return Err(error); + } + Ok(()) +} + +async fn write_and_close( + writable: &web_sys::FileSystemWritableFileStream, + bytes: &[u8], +) -> Result<(), String> { let write = writable.write_with_u8_array(bytes).map_err(js_error)?; JsFuture::from(write).await.map_err(js_error)?; JsFuture::from(writable.close()).await.map_err(js_error)?; @@ -259,9 +277,22 @@ fn decode_thumbnail(bytes: Vec) -> Option { /// Shared with the autosave snapshot store (`web_autosave`). pub(crate) fn js_error(value: wasm_bindgen::JsValue) -> String { - value - .as_string() - .unwrap_or_else(|| format!("browser storage error: {value:?}")) + if let Some(text) = value.as_string() { + return text; + } + // OPFS failures surface as DOMExceptions, which stringify as + // "[object DOMException]" — useless in a command-line warning. Their + // `name`/`message` properties carry the diagnosis ("QuotaExceededError", + // "NotFoundError", …), so render those when present. + let property = |key: &str| { + js_sys::Reflect::get(&value, &wasm_bindgen::JsValue::from_str(key)) + .ok() + .and_then(|found| found.as_string()) + }; + match (property("name"), property("message")) { + (Some(name), Some(message)) => format!("{name}: {message}"), + _ => format!("browser storage error: {value:?}"), + } } /// Last-modified time (ms since the Unix epoch) of a drawing's cached copy,