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.
This commit is contained in:
1 parent
101d167ebc
commit
3edbc5b7c8
8 files changed
+386
-118
No files matched your search
+16
-2
@@ -736,6 +736,16 @@ pub(super) struct OpenCADStudio {
|
|||||||
/// prompt still has to offer, one at a time.
|
/// prompt still has to offer, one at a time.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
|
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 ────────────────────────────────────────────
|
// ── Floating panel windows ────────────────────────────────────────────
|
||||||
/// Active `iced_aw` colour picker: destination plus its initial colour.
|
/// Active `iced_aw` colour picker: destination plus its initial colour.
|
||||||
color_pick_target: Option<(ColorPickTarget, AcadColor)>,
|
color_pick_target: Option<(ColorPickTarget, AcadColor)>,
|
||||||
@@ -2021,8 +2031,8 @@ pub enum Message {
|
|||||||
/// Web: delete the offered autosave snapshot (and offer the next, if any).
|
/// Web: delete the offered autosave snapshot (and offer the next, if any).
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
WebAutosaveDiscard,
|
WebAutosaveDiscard,
|
||||||
/// Web: the offered snapshot's bytes are in (and the OPFS copy consumed);
|
/// Web: the offered snapshot's bytes are in; queue them for the guarded
|
||||||
/// open them as `name`.
|
/// open poll. The OPFS copy is consumed only once that open succeeds.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
WebAutosaveBytes(String, Result<Vec<u8>, String>),
|
WebAutosaveBytes(String, Result<Vec<u8>, String>),
|
||||||
/// Web: one autosave round finished for all dirty tabs (serially).
|
/// Web: one autosave round finished for all dirty tabs (serially).
|
||||||
@@ -3769,6 +3779,10 @@ impl OpenCADStudio {
|
|||||||
web_autosave_warned: false,
|
web_autosave_warned: false,
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
web_autosave_pending: Vec::new(),
|
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_pick_target: None,
|
||||||
color_picker_tab: ColorPickerTab::Index,
|
color_picker_tab: ColorPickerTab::Index,
|
||||||
recent_colors: Vec::new(),
|
recent_colors: Vec::new(),
|
||||||
|
|||||||
+19
-5
@@ -26,14 +26,21 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
/// Queue the web crash-recovery prompt behind whatever startup modal is
|
/// Queue the web crash-recovery prompt behind whatever startup modal is
|
||||||
/// already showing (donation, GPU warning…); the after-message hook in
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn queue_autosave_restore_prompt(&mut self) {
|
pub(super) fn queue_autosave_restore_prompt(&mut self) {
|
||||||
|
if self.web_autosave_pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !self
|
if !self
|
||||||
.pending_startup_modals
|
.pending_startup_modals
|
||||||
.contains(&ModalKind::AutosaveRestore)
|
.contains(&ModalKind::AutosaveRestore)
|
||||||
{
|
{
|
||||||
self.pending_startup_modals.push_back(ModalKind::AutosaveRestore);
|
self.pending_startup_modals
|
||||||
|
.push_back(ModalKind::AutosaveRestore);
|
||||||
}
|
}
|
||||||
self.show_next_startup_modal();
|
self.show_next_startup_modal();
|
||||||
}
|
}
|
||||||
@@ -62,7 +69,8 @@ impl OpenCADStudio {
|
|||||||
};
|
};
|
||||||
match &self.gpu_status {
|
match &self.gpu_status {
|
||||||
GpuStatus::Software(adapter) => {
|
GpuStatus::Software(adapter) => {
|
||||||
self.command_line.push_warning(&software_rasterizer_warning(adapter));
|
self.command_line
|
||||||
|
.push_warning(&software_rasterizer_warning(adapter));
|
||||||
}
|
}
|
||||||
GpuStatus::NoRenderer => {
|
GpuStatus::NoRenderer => {
|
||||||
crate::scene::pipeline::report_gpu_line(
|
crate::scene::pipeline::report_gpu_line(
|
||||||
@@ -141,9 +149,15 @@ mod tests {
|
|||||||
// The name carries parentheses and commas of its own; the localized
|
// The name carries parentheses and commas of its own; the localized
|
||||||
// template must still place it whole. The loader follows the system
|
// template must still place it whole. The loader follows the system
|
||||||
// locale here, so the assertions stay language-neutral.
|
// locale here, so the assertions stay language-neutral.
|
||||||
assert!(warning.contains("(llvmpipe (LLVM 20.1.8, 256 bits))"), "{warning}");
|
assert!(
|
||||||
|
warning.contains("(llvmpipe (LLVM 20.1.8, 256 bits))"),
|
||||||
|
"{warning}"
|
||||||
|
);
|
||||||
assert!(warning.contains("GPU"), "{warning}");
|
assert!(warning.contains("GPU"), "{warning}");
|
||||||
assert!(!warning.contains("{adapter}") && !warning.contains("__ocs_"), "{warning}");
|
assert!(
|
||||||
|
!warning.contains("{adapter}") && !warning.contains("__ocs_"),
|
||||||
|
"{warning}"
|
||||||
|
);
|
||||||
// Every platform has a hint, and it is a sentence, not a missing key.
|
// Every platform has a hint, and it is a sentence, not a missing key.
|
||||||
let hint = gpu_platform_hint();
|
let hint = gpu_platform_hint();
|
||||||
assert!(hint.len() > 20 && !hint.contains("hint-"), "{hint}");
|
assert!(hint.len() > 20 && !hint.contains("hint-"), "{hint}");
|
||||||
|
|||||||
+113
-48
@@ -1936,22 +1936,33 @@ impl OpenCADStudio {
|
|||||||
);
|
);
|
||||||
// A clean save supersedes any crash-recovery snapshot for this
|
// A clean save supersedes any crash-recovery snapshot for this
|
||||||
// drawing — drop the old key and the new one so stale copies
|
// 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]
|
let new_key = self.tabs[i]
|
||||||
.current_path
|
.current_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|path| path.to_string_lossy().into_owned())
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
recent_task = Task::batch([
|
if self.web_autosave_in_flight {
|
||||||
recent_task,
|
self.web_autosave_clear_after.push(autosave_key);
|
||||||
Task::perform(
|
if !new_key.is_empty() {
|
||||||
async move {
|
self.web_autosave_clear_after.push(new_key);
|
||||||
let _ = crate::io::web_autosave::remove(&autosave_key).await;
|
}
|
||||||
let _ = crate::io::web_autosave::remove(&new_key).await;
|
} else {
|
||||||
},
|
recent_task = Task::batch([
|
||||||
|_| Message::Noop,
|
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
|
self.command_line
|
||||||
.push_output(crate::tf!("Saved: {filename}").as_ref());
|
.push_output(crate::tf!("Saved: {filename}").as_ref());
|
||||||
true
|
true
|
||||||
@@ -2964,10 +2975,21 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One web autosave round finished. Silent on success; on failure one
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task<Message> {
|
pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task<Message> {
|
||||||
self.web_autosave_in_flight = false;
|
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 {
|
match result {
|
||||||
Ok(()) => self.web_autosave_warned = false,
|
Ok(()) => self.web_autosave_warned = false,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -2983,7 +3005,10 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The boot scan decided which snapshots are recoverable; queue the
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_autosaves_scanned(
|
pub(super) fn on_web_autosaves_scanned(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -2999,8 +3024,12 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the offered snapshot: read (and consume) the OPFS copy, then
|
/// Restore the offered snapshot: read the OPFS copy — leaving it in
|
||||||
/// open it as a recovered tab through the standard web-bytes open flow.
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_autosave_restore(&mut self) -> Task<Message> {
|
pub(super) fn on_web_autosave_restore(&mut self) -> Task<Message> {
|
||||||
let Some(entry) = self.web_autosave_pending.first().cloned() else {
|
let Some(entry) = self.web_autosave_pending.first().cloned() else {
|
||||||
@@ -3011,18 +3040,37 @@ impl OpenCADStudio {
|
|||||||
self.close_active_modal();
|
self.close_active_modal();
|
||||||
let open_name = crate::io::web_autosave::recovered_name(&entry.name);
|
let open_name = crate::io::web_autosave::recovered_name(&entry.name);
|
||||||
let key = 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(
|
Task::perform(
|
||||||
async move {
|
async move { crate::io::web_autosave::read(&key).await },
|
||||||
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)
|
|
||||||
},
|
|
||||||
move |result| Message::WebAutosaveBytes(open_name, result),
|
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.
|
/// Discard the offered snapshot; the prompt stays up for the next one.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_autosave_discard(&mut self) -> Task<Message> {
|
pub(super) fn on_web_autosave_discard(&mut self) -> Task<Message> {
|
||||||
@@ -3038,8 +3086,13 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offered snapshot's bytes arrived (or did not): open the recovered
|
/// The offered snapshot's bytes arrived (or did not). On success the
|
||||||
/// tab, or warn and move on to the next snapshot.
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_autosave_bytes(
|
pub(super) fn on_web_autosave_bytes(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -3048,31 +3101,33 @@ impl OpenCADStudio {
|
|||||||
) -> Task<Message> {
|
) -> Task<Message> {
|
||||||
match result {
|
match result {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
self.command_line.push_output(
|
// The next prompt is queued only when this open settles
|
||||||
crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref(),
|
// (`web_autosave_opened` / the failure hook), not here — the
|
||||||
);
|
// poll has not started the open yet, so queueing now would
|
||||||
let task = self.update(Message::WebFileDropped(open_name, bytes));
|
// show the modal before the recovered drawing loads.
|
||||||
// Further snapshots wait behind this one; queue the prompt
|
crate::io::web_drop::enqueue_open(open_name, bytes);
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
Task::none()
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3723,8 +3778,18 @@ impl OpenCADStudio {
|
|||||||
let width = ((max.x - min.x) as f64 * margin).max(1.0);
|
let width = ((max.x - min.x) as f64 * margin).max(1.0);
|
||||||
let height = ((max.y - min.y) 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 {
|
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));
|
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 {
|
} else {
|
||||||
let pad_x = (width - (max.x - min.x) as f64) * 0.5;
|
let pad_x = (width - (max.x - min.x) as f64) * 0.5;
|
||||||
let pad_y = (height - (max.y - min.y) as f64) * 0.5;
|
let pad_y = (height - (max.y - min.y) as f64) * 0.5;
|
||||||
|
|||||||
+32
-5
@@ -811,6 +811,13 @@ impl OpenCADStudio {
|
|||||||
self.command_line
|
self.command_line
|
||||||
.push_info(crate::tf!("Open cancelled: \"{}\"", p.name).as_ref());
|
.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()
|
self.drain_pending_open()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,7 +825,13 @@ impl OpenCADStudio {
|
|||||||
Message::WebFileDropped(name, bytes) => {
|
Message::WebFileDropped(name, bytes) => {
|
||||||
// Bytes are already in hand (no picker round-trip), so this is
|
// 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
|
// `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(
|
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||||
crate::app::OPEN_PHASE_READING,
|
crate::app::OPEN_PHASE_READING,
|
||||||
));
|
));
|
||||||
@@ -845,6 +858,22 @@ impl OpenCADStudio {
|
|||||||
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
|
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
|
||||||
return Task::none();
|
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() {
|
if let Some(opening) = self.opening.as_mut() {
|
||||||
opening.name = outcome.name.clone();
|
opening.name = outcome.name.clone();
|
||||||
opening.source_path = Some(std::path::PathBuf::from(&outcome.name));
|
opening.source_path = Some(std::path::PathBuf::from(&outcome.name));
|
||||||
@@ -4455,8 +4484,7 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::AutoConstrainReset => {
|
Message::AutoConstrainReset => {
|
||||||
self.auto_constrain_settings =
|
self.auto_constrain_settings = super::settings::AutoConstrainSettings::default();
|
||||||
super::settings::AutoConstrainSettings::default();
|
|
||||||
self.auto_constrain_selected_row = 0;
|
self.auto_constrain_selected_row = 0;
|
||||||
self.auto_constrain_distance_input =
|
self.auto_constrain_distance_input =
|
||||||
format!("{}", self.auto_constrain_settings.distance_tolerance);
|
format!("{}", self.auto_constrain_settings.distance_tolerance);
|
||||||
@@ -4495,8 +4523,7 @@ impl OpenCADStudio {
|
|||||||
self.auto_constrain_settings.distance_tolerance = distance;
|
self.auto_constrain_settings.distance_tolerance = distance;
|
||||||
self.auto_constrain_settings.angle_tolerance_deg = angle;
|
self.auto_constrain_settings.angle_tolerance_deg = angle;
|
||||||
self.auto_constrain_settings.sanitize();
|
self.auto_constrain_settings.sanitize();
|
||||||
self.auto_constrain_saved =
|
self.auto_constrain_saved = Some(self.auto_constrain_settings.clone());
|
||||||
Some(self.auto_constrain_settings.clone());
|
|
||||||
self.persist_settings_if_changed();
|
self.persist_settings_if_changed();
|
||||||
if matches!(action, Message::AutoConstrainOk) {
|
if matches!(action, Message::AutoConstrainOk) {
|
||||||
self.close_active_modal();
|
self.close_active_modal();
|
||||||
|
|||||||
+13
-11
@@ -832,17 +832,19 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
self.constraint_bar_display,
|
self.constraint_bar_display,
|
||||||
)
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, point, direction, label, is_conflicting, hover_points)| {
|
.map(
|
||||||
let selected = tab.scene.selected_constraint == Some(id);
|
|(id, point, direction, label, is_conflicting, hover_points)| {
|
||||||
(
|
let selected = tab.scene.selected_constraint == Some(id);
|
||||||
point,
|
(
|
||||||
direction,
|
point,
|
||||||
label,
|
direction,
|
||||||
is_conflicting,
|
label,
|
||||||
selected,
|
is_conflicting,
|
||||||
hover_points,
|
selected,
|
||||||
)
|
hover_points,
|
||||||
})
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
crate::ui::overlay::selection_overlay(
|
crate::ui::overlay::selection_overlay(
|
||||||
|
|||||||
+98
-11
@@ -8,11 +8,18 @@
|
|||||||
//! offered for restore at boot when newer than the drawing's last explicit
|
//! offered for restore at boot when newer than the drawing's last explicit
|
||||||
//! save (the OPFS recent copy of the same name).
|
//! 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
|
//! 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
|
//! `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
|
//! is written before its snapshot: a crash between the two writes leaves a
|
||||||
//! name without bytes, which `list` filters out, rather than an orphan
|
//! name without bytes — a missing file or a zero-byte one — which `list`
|
||||||
//! snapshot nobody can name.
|
//! filters out, rather than an orphan snapshot nobody can name.
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
@@ -38,6 +45,9 @@ pub struct WebAutosaveEntry {
|
|||||||
/// A snapshot is worth offering when it is strictly newer than the last
|
/// 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
|
/// 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).
|
/// 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
|
#[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<f64>) -> bool {
|
pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option<f64>) -> bool {
|
||||||
match saved_ms {
|
match saved_ms {
|
||||||
@@ -46,6 +56,33 @@ pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option<f64>) -> 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<WebAutosaveEntry>,
|
||||||
|
cap: usize,
|
||||||
|
) -> (Vec<WebAutosaveEntry>, Vec<String>) {
|
||||||
|
entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
|
||||||
|
let overflow = entries.len().saturating_sub(cap);
|
||||||
|
let mut evicted: Vec<String> = 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
|
/// 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
|
/// stem plus " (recovered)" and its own extension (DWG when the stored name
|
||||||
/// has none, matching how autosave serializes untitled tabs). The extension
|
/// 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,
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn list() -> Result<Vec<WebAutosaveEntry>, String> {
|
pub async fn list() -> Result<Vec<WebAutosaveEntry>, String> {
|
||||||
let directory = autosave_directory(false).await?;
|
let directory = autosave_directory(false).await?;
|
||||||
@@ -98,6 +138,9 @@ pub async fn list() -> Result<Vec<WebAutosaveEntry>, String> {
|
|||||||
let Ok(file) = super::web_recent::get_file(&directory, &snapshot_key(&name)).await else {
|
let Ok(file) = super::web_recent::get_file(&directory, &snapshot_key(&name)).await else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if file.size() == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
entries.push(WebAutosaveEntry {
|
entries.push(WebAutosaveEntry {
|
||||||
name,
|
name,
|
||||||
size: file.size() as u64,
|
size: file.size() as u64,
|
||||||
@@ -130,22 +173,26 @@ pub async fn remove(name: &str) -> Result<(), String> {
|
|||||||
drawing.map(|_| ())
|
drawing.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Boot scan: keep the snapshots worth offering (newer than the drawing's
|
/// Boot scan: offer the snapshots worth recovering — strictly newer than the
|
||||||
/// last explicit save, newest first) and silently delete the stale ones — a
|
/// drawing's last explicit copy, newest first. Age never deletes: the recent
|
||||||
/// snapshot older than the saved copy recovers nothing and would otherwise
|
/// copy is refreshed by every OPEN as well as every save, so a snapshot that
|
||||||
/// pile up across sessions.
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn scan() -> Result<Vec<WebAutosaveEntry>, String> {
|
pub async fn scan() -> Result<Vec<WebAutosaveEntry>, String> {
|
||||||
|
let (kept, evicted) = cap_evictions(list().await?, SNAPSHOT_CAP);
|
||||||
|
for name in evicted {
|
||||||
|
let _ = remove(&name).await;
|
||||||
|
}
|
||||||
let mut recoverable = Vec::new();
|
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;
|
let saved_ms = super::web_recent::modified_ms(&entry.name).await;
|
||||||
if newer_than_saved(entry.modified_ms, saved_ms) {
|
if newer_than_saved(entry.modified_ms, saved_ms) {
|
||||||
recoverable.push(entry);
|
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)
|
Ok(recoverable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,4 +266,44 @@ mod tests {
|
|||||||
assert_eq!(recovered_name("a.b.dwg"), "a.b (recovered).dwg");
|
assert_eq!(recovered_name("a.b.dwg"), "a.b (recovered).dwg");
|
||||||
assert_eq!(recovered_name(""), " (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::<Vec<_>>(),
|
||||||
|
["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::<Vec<_>>(),
|
||||||
|
["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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+60
-29
@@ -5,7 +5,8 @@
|
|||||||
//! queues them for the 50 ms control poll to open through the web-picker flow.
|
//! queues them for the 50 ms control poll to open through the web-picker flow.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
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::JsCast;
|
||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
@@ -14,14 +15,25 @@ use wasm_bindgen_futures::JsFuture;
|
|||||||
const QUEUE_CAP: usize = 8;
|
const QUEUE_CAP: usize = 8;
|
||||||
|
|
||||||
static DROPPED: Mutex<VecDeque<(String, Vec<u8>)>> = Mutex::new(VecDeque::new());
|
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) {
|
fn warn(line: &str) {
|
||||||
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line));
|
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>) {
|
fn enqueue(name: String, bytes: Vec<u8>) {
|
||||||
let mut queue = DROPPED.lock().unwrap();
|
let mut queue = lock_queue();
|
||||||
if queue.len() >= QUEUE_CAP {
|
if queue.len() >= QUEUE_CAP {
|
||||||
drop(queue);
|
drop(queue);
|
||||||
warn(&format!(
|
warn(&format!(
|
||||||
@@ -32,6 +44,12 @@ fn enqueue(name: String, bytes: Vec<u8>) {
|
|||||||
queue.push_back((name, bytes));
|
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
|
/// JS-callable entry (same shape as `ocs_control_submit`) for hosts that
|
||||||
/// forward drops themselves.
|
/// forward drops themselves.
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
#[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.
|
/// Pop the oldest dropped file that the poll has not started opening yet.
|
||||||
pub fn take_dropped() -> Option<(String, Vec<u8>)> {
|
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
|
/// 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`
|
/// Register the document-level `dragenter`/`dragover`/`drop` listeners once.
|
||||||
/// MUST preventDefault or the browser navigates to the dropped file instead
|
/// `dragover` MUST preventDefault or the browser navigates to the dropped file
|
||||||
/// of delivering it to the page.
|
/// instead of delivering it to the page.
|
||||||
pub fn install() {
|
pub fn install() {
|
||||||
if INSTALLED.set(()).is_err() {
|
if INSTALLED.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(document) = web_sys::window().and_then(|window| window.document()) else {
|
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;
|
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| {
|
move |event: web_sys::Event| {
|
||||||
event.prevent_default();
|
event.prevent_default();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ =
|
// Canceling `dragenter` too is belt-and-braces some engines still want
|
||||||
document.add_event_listener_with_callback("dragover", on_dragover.as_ref().unchecked_ref());
|
// before treating the page as a drop target.
|
||||||
on_dragover.forget();
|
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(
|
let on_drop = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(
|
||||||
move |event: web_sys::Event| {
|
move |event: web_sys::Event| {
|
||||||
event.prevent_default();
|
event.prevent_default();
|
||||||
event.stop_propagation();
|
|
||||||
let Some(drag) = event.dyn_ref::<web_sys::DragEvent>() else {
|
let Some(drag) = event.dyn_ref::<web_sys::DragEvent>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(files) = drag.data_transfer().and_then(|transfer| transfer.files()) else {
|
let Some(files) = drag.data_transfer().and_then(|transfer| transfer.files()) else {
|
||||||
return;
|
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() {
|
for index in 0..files.length() {
|
||||||
let Some(file) = files.get(index) else {
|
let Some(file) = files.get(index) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -113,15 +140,27 @@ pub fn install() {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
reads.push((name, file.array_buffer()));
|
||||||
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}"),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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());
|
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")
|
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())
|
|
||||||
}
|
|
||||||
+35
-7
@@ -90,10 +90,7 @@ pub async fn store_open(
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
if state.latest_id != pending_id {
|
if state.latest_id != pending_id {
|
||||||
return Some((
|
return Some((state.latest_id, std::sync::Arc::clone(&state.latest_bytes)));
|
||||||
state.latest_id,
|
|
||||||
std::sync::Arc::clone(&state.latest_bytes),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
state.active_writers = state.active_writers.saturating_sub(1);
|
state.active_writers = state.active_writers.saturating_sub(1);
|
||||||
if state.active_writers == 0 {
|
if state.active_writers == 0 {
|
||||||
@@ -130,6 +127,24 @@ pub(crate) async fn write_entry(
|
|||||||
.map_err(js_error)?
|
.map_err(js_error)?
|
||||||
.dyn_into::<web_sys::FileSystemWritableFileStream>()
|
.dyn_into::<web_sys::FileSystemWritableFileStream>()
|
||||||
.map_err(js_error)?;
|
.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)?;
|
let write = writable.write_with_u8_array(bytes).map_err(js_error)?;
|
||||||
JsFuture::from(write).await.map_err(js_error)?;
|
JsFuture::from(write).await.map_err(js_error)?;
|
||||||
JsFuture::from(writable.close()).await.map_err(js_error)?;
|
JsFuture::from(writable.close()).await.map_err(js_error)?;
|
||||||
@@ -262,9 +277,22 @@ fn decode_thumbnail(bytes: Vec<u8>) -> Option<Thumbnail> {
|
|||||||
|
|
||||||
/// Shared with the autosave snapshot store (`web_autosave`).
|
/// Shared with the autosave snapshot store (`web_autosave`).
|
||||||
pub(crate) fn js_error(value: wasm_bindgen::JsValue) -> String {
|
pub(crate) fn js_error(value: wasm_bindgen::JsValue) -> String {
|
||||||
value
|
if let Some(text) = value.as_string() {
|
||||||
.as_string()
|
return text;
|
||||||
.unwrap_or_else(|| format!("browser storage error: {value:?}"))
|
}
|
||||||
|
// 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,
|
/// Last-modified time (ms since the Unix epoch) of a drawing's cached copy,
|
||||||
|
|||||||
Reference in new issue
Block a user