diff --git a/src/app/mod.rs b/src/app/mod.rs index 2205804b4..120eacd44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -927,11 +927,12 @@ 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. + /// The snapshot whose recovered open is in flight: `(autosave key, + /// open id)`. The OPFS copy is the only one, so it is deleted only + /// once the open with exactly that id succeeds — identity, never the + /// display name (same-named files share one stored copy on web). #[cfg(target_arch = "wasm32")] - web_autosave_consumed: Option<(String, String)>, + web_autosave_consumed: Option<(String, u64)>, /// 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. @@ -2260,8 +2261,10 @@ pub enum Message { FileDropped(PathBuf), /// Web only: a drawing dragged onto the page — the DOM drop listener has /// already read the bytes; this opens through the web-picker flow (#344). + /// The third field tags an autosave-restore file with its snapshot key so + /// the snapshot is deleted only when this exact open succeeds. #[cfg(target_arch = "wasm32")] - WebFileDropped(String, Vec), + WebFileDropped(String, Vec, Option), /// Web only: Ctrl+V captured by a focused text field, whose iced-internal /// paste is inert there — read the async clipboard instead (#346). WebFieldPaste, @@ -2337,7 +2340,7 @@ pub enum Message { /// 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>), + WebAutosaveBytes(String, String, Result, String>), /// Web: one autosave round finished for all dirty tabs (serially). #[cfg(target_arch = "wasm32")] WebAutosaveSnapshotFinished(Result<(), String>), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5a9fea58a..cf2fc1ad0 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -3684,29 +3684,24 @@ 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())); + // The snapshot key travels with the bytes: the marker (and the + // deletion it authorises) is recorded only when the poll actually + // starts this open, keyed by that open's id — never by display name. + let read_key = key.clone(); Task::perform( - async move { crate::io::web_autosave::read(&key).await }, - move |result| Message::WebAutosaveBytes(open_name, result), + async move { crate::io::web_autosave::read(&read_key).await }, + move |result| Message::WebAutosaveBytes(open_name, key, 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. + /// offered. The caller already matched the marker to this open's id. #[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 { + let Some((key, _)) = self.web_autosave_consumed.take() 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 { @@ -3741,6 +3736,7 @@ impl OpenCADStudio { pub(super) fn on_web_autosave_bytes( &mut self, open_name: String, + key: String, result: Result, String>, ) -> Task { match result { @@ -3749,7 +3745,7 @@ impl OpenCADStudio { // (`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); + crate::io::web_drop::enqueue_restore(open_name, bytes, key); Task::none() } Err(error) => { @@ -3757,7 +3753,6 @@ impl OpenCADStudio { // 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(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 7d7ac241d..75246d331 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -675,7 +675,13 @@ impl OpenCADStudio { && self.active_modal != Some(super::ModalKind::Recovery) { crate::io::web_drop::take_dropped() - .map(|(name, bytes)| self.update(Message::WebFileDropped(name, bytes))) + .map(|file| { + self.update(Message::WebFileDropped( + file.name, + file.bytes, + file.restore_key, + )) + }) .unwrap_or_else(Task::none) } else { Task::none() @@ -1097,35 +1103,56 @@ impl OpenCADStudio { } Message::OpenCancel => { + #[cfg(target_arch = "wasm32")] + let cancelled_id = self.opening.as_ref().map(|opening| opening.id); if let Some(p) = self.opening.take() { 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. + // snapshots can still be offered this session. Match on the + // open's id so cancelling an unrelated open leaves a pending + // restore marker alone. #[cfg(target_arch = "wasm32")] - if self.web_autosave_consumed.take().is_some() { + if cancelled_id + .is_some_and(|id| { + self.web_autosave_consumed + .as_ref() + .is_some_and(|(_, recorded)| *recorded == id) + }) + && self.web_autosave_consumed.take().is_some() + { self.queue_next_web_autosave_prompt(); } self.drain_pending_open() } #[cfg(target_arch = "wasm32")] - Message::WebFileDropped(name, bytes) => { + Message::WebFileDropped(name, bytes, restore_key) => { // 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. 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. + // entry goes back to the queue for the next poll tick. if self.opening.is_some() { - crate::io::web_drop::enqueue_open(name, bytes); + match restore_key { + Some(key) => { + crate::io::web_drop::enqueue_restore(name, bytes, key) + } + None => 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, )); let open_id = self.next_open_id(); + // Identity, not display name: only this exact open may delete + // the snapshot the restore came from. + if let Some(key) = restore_key { + self.web_autosave_consumed = Some((key, open_id)); + } self.opening = Some(crate::app::OpenProgress { id: open_id, name: name.clone(), @@ -1155,7 +1182,7 @@ impl OpenCADStudio { if self .web_autosave_consumed .as_ref() - .is_some_and(|(_, recorded)| *recorded == outcome.name) + .is_some_and(|(_, recorded)| *recorded == open_id) { if outcome.result.is_ok() { self.web_autosave_opened(&outcome.name); @@ -1285,7 +1312,9 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] Message::WebAutosaveDiscard => self.on_web_autosave_discard(), #[cfg(target_arch = "wasm32")] - Message::WebAutosaveBytes(name, result) => self.on_web_autosave_bytes(name, result), + Message::WebAutosaveBytes(name, key, result) => { + self.on_web_autosave_bytes(name, key, result) + } #[cfg(target_arch = "wasm32")] Message::WebAutosaveSnapshotFinished(result) => self.on_web_autosave_finished(result), diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs index 961304a9f..cdfc53a6e 100644 --- a/src/io/web_autosave.rs +++ b/src/io/web_autosave.rs @@ -125,30 +125,7 @@ pub async fn snapshot(name: &str, bytes: &[u8], thumbnail: Option<&[u8]>) -> Res Ok(()) } -/// All live snapshots. Index rows whose file is gone (crash between writes, -/// 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?; - let mut entries = Vec::new(); - for name in read_index(&directory).await { - 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, - modified_ms: file.last_modified(), - }); - } - Ok(entries) -} + #[cfg(target_arch = "wasm32")] pub async fn read(name: &str) -> Result, String> { @@ -181,7 +158,35 @@ pub async fn remove(name: &str) -> Result<(), String> { /// 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); + let directory = autosave_directory(false).await?; + let mut entries = Vec::new(); + let mut dead: Vec = Vec::new(); + for name in read_index(&directory).await { + let file = match super::web_recent::get_file(&directory, &snapshot_key(&name)).await { + Ok(file) => file, + // Crash between the index and snapshot writes: the row points + // at nothing recoverable. + Err(_) => { + dead.push(name); + continue; + } + }; + if file.size() == 0.0 { + dead.push(name); + continue; + } + entries.push(WebAutosaveEntry { + name, + size: file.size() as u64, + modified_ms: file.last_modified(), + }); + } + // Dead rows are junk no other path removes: they never appear in an + // offer, so the user cannot discard them and the cap cannot evict them. + for name in dead { + let _ = remove(&name).await; + } + let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP); for name in evicted { let _ = remove(&name).await; } diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs index 0bf027acf..f8083cf49 100644 --- a/src/io/web_drop.rs +++ b/src/io/web_drop.rs @@ -14,7 +14,16 @@ use wasm_bindgen_futures::JsFuture; /// rejected with a console warning instead of being silently dropped. const QUEUE_CAP: usize = 8; -static DROPPED: Mutex)>> = Mutex::new(VecDeque::new()); +/// One queued open. `restore_key` tags an autosave-restore file so its +/// snapshot is deleted only when THIS open succeeds — matching by identity, +/// never by display name (same-named files are one OPFS copy on web). +pub struct QueuedOpen { + pub name: String, + pub bytes: Vec, + pub restore_key: Option, +} + +static DROPPED: Mutex> = Mutex::new(VecDeque::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. @@ -24,7 +33,7 @@ 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)>> { +fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque> { // The critical sections are push/pop only, so poisoning is unreachable; // recover anyway rather than panic across the JS boundary. DROPPED @@ -32,7 +41,8 @@ fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque<(String, Vec)>> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } -fn enqueue(name: String, bytes: Vec) { +fn push(entry: QueuedOpen) { + let name = entry.name.clone(); let mut queue = lock_queue(); if queue.len() >= QUEUE_CAP { drop(queue); @@ -41,24 +51,37 @@ fn enqueue(name: String, bytes: Vec) { )); return; } - queue.push_back((name, bytes)); + queue.push_back(entry); } -/// Queue already-read drawing bytes (drag-and-drop, autosave restore) for the -/// 50 ms poll to open through the guarded web-picker flow. +/// Queue already-read drawing bytes (drag-and-drop) for the 50 ms poll to +/// open through the guarded web-picker flow. pub fn enqueue_open(name: String, bytes: Vec) { - enqueue(name, bytes); + push(QueuedOpen { + name, + bytes, + restore_key: None, + }); +} + +/// Queue an autosave-restore file tagged with its snapshot key. +pub fn enqueue_restore(name: String, bytes: Vec, restore_key: String) { + push(QueuedOpen { + name, + bytes, + restore_key: Some(restore_key), + }); } /// JS-callable entry (same shape as `ocs_control_submit`) for hosts that /// forward drops themselves. #[wasm_bindgen::prelude::wasm_bindgen] pub fn ocs_web_drop_file(name: String, bytes: js_sys::Uint8Array) { - enqueue(name, bytes.to_vec()); + enqueue_open(name, bytes.to_vec()); } /// Pop the oldest dropped file that the poll has not started opening yet. -pub fn take_dropped() -> Option<(String, Vec)> { +pub fn take_dropped() -> Option { lock_queue().pop_front() } @@ -109,8 +132,12 @@ pub fn install() { ); // 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()); + let cancel_ok = document + .add_event_listener_with_callback("dragenter", cancel.as_ref().unchecked_ref()) + .is_ok() + & document + .add_event_listener_with_callback("dragover", cancel.as_ref().unchecked_ref()) + .is_ok(); cancel.forget(); let on_drop = wasm_bindgen::closure::Closure::::new( @@ -151,7 +178,9 @@ pub fn install() { 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()), + Ok(buffer) => { + enqueue_open(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}\": {}", @@ -163,8 +192,14 @@ pub fn install() { }); }, ); - let _ = document.add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref()); + let drop_ok = document + .add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref()) + .is_ok(); on_drop.forget(); + if !cancel_ok || !drop_ok { + // Unlatch so a later install can retry with fresh listeners. + INSTALLED.store(false, Ordering::SeqCst); + } } fn is_drawing_name(name: &str) -> bool {