web: harden drop-open and autosave after adversarial review

Review of the drop-open and autosave commits surfaced one blocker and
five majors; all are fixed here, and two claims in the autosave commit
message are corrected by this one: tab close does NOT clear a snapshot
(matches native .sv$ semantics — saving, restoring, discarding, exit,
and the capped boot scan do), and serialization runs on the main
thread between awaits, not off the paint path.

- The restore prompt can no longer appear with no entries (empty
  "Recover Drawing" modal on every boot once the autosave dir exists).
- Restore no longer consumes the snapshot before the open succeeds:
  the recovered drawing goes through the guarded drop queue, and the
  snapshot is deleted only after the tab opens; cancel or parse
  failure keeps the only copy.
- Saving during an autosave round defers its snapshot cleanup until
  the round finishes, so a stale round can no longer resurrect a
  snapshot newer than the save.
- The boot scan no longer deletes snapshots by mtime comparison
  (re-opening a drawing refreshes the recent copy's mtime, which
  silently destroyed wanted snapshots); GC is now a 16-entry cap with
  oldest-first eviction.
- OPFS write failures abort the writable stream (a leaked lock could
  stall the same-key writer and disable autosave for the session), and
  DOMException errors now carry their name/message.
- Empty (crash-window) snapshots are never offered; an unreadable one
  warns and stays discardable instead of looping forever.
- Drop-open polish: reads start inside the handler and complete in
  drop order (legacy WebKit safety, deterministic multi-file order),
  dragenter is canceled too, the install guard only latches on
  success, non-drawing drops don't steal propagation, a lock-poison
  panic can't cross into JS, and re-dropping an open drawing no
  longer risks clobbering an in-flight open (it re-queues).
- The model-space sheet floor is per-axis (a thin 8x400 mm profile
  keeps its length) and centers content on the enlarged page.
This commit is contained in:
Afonsop committed 2026-09-16 10:22:25 +01:00
1 parent 101d167ebc
commit 3edbc5b7c8
8 files changed
+386 -118

No files matched your search

+32 -5
View File
@@ -811,6 +811,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()
}
@@ -818,7 +825,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,
));
@@ -845,6 +858,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));
@@ -4455,8 +4484,7 @@ impl OpenCADStudio {
Task::none()
}
Message::AutoConstrainReset => {
self.auto_constrain_settings =
super::settings::AutoConstrainSettings::default();
self.auto_constrain_settings = super::settings::AutoConstrainSettings::default();
self.auto_constrain_selected_row = 0;
self.auto_constrain_distance_input =
format!("{}", self.auto_constrain_settings.distance_tolerance);
@@ -4495,8 +4523,7 @@ impl OpenCADStudio {
self.auto_constrain_settings.distance_tolerance = distance;
self.auto_constrain_settings.angle_tolerance_deg = angle;
self.auto_constrain_settings.sanitize();
self.auto_constrain_saved =
Some(self.auto_constrain_settings.clone());
self.auto_constrain_saved = Some(self.auto_constrain_settings.clone());
self.persist_settings_if_changed();
if matches!(action, Message::AutoConstrainOk) {
self.close_active_modal();