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:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 12fd04790f
commit 3023cefea4
7 files changed
+360 -96

No files matched your search

+60 -29
View File
@@ -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<VecDeque<(String, Vec<u8>)>> = 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<u8>)>> {
// 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<u8>) {
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<u8>) {
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<u8>) {
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<u8>)> {
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::<dyn FnMut(web_sys::Event)>::new(
let cancel = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::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::<dyn FnMut(web_sys::Event)>::new(
move |event: web_sys::Event| {
event.prevent_default();
event.stop_propagation();
let Some(drag) = event.dyn_ref::<web_sys::DragEvent>() 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<Vec<u8>, 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())
}