forked from 1b-agent/opencadstudio-upstream
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.
(cherry picked from commit 3edbc5b7c8)
This commit is contained in:
1 parent
12fd04790f
commit
3023cefea4
7 files changed
+360
-96
No files matched your search
+16
-2
@@ -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<crate::io::web_autosave::WebAutosaveEntry>,
|
||||
/// 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<String>,
|
||||
// ── 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<Vec<u8>, 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(),
|
||||
|
||||
+9
-2
@@ -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();
|
||||
}
|
||||
|
||||
+113
-48
@@ -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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
@@ -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<Message> {
|
||||
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;
|
||||
|
||||
+30
-1
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user