fix: close the adversarial findings on the save/paste wave

Ported from da950460 (#1717).

In-place save:
- A stale retained handle could receive bytes serialized for a
  DIFFERENT format (Save As .dwg->.dxf whose re-pick failed wrote DXF
  bytes through the .dwg handle) or silently save in place while the
  user was told a copy was downloading. The serialized format now
  travels to completion (tab-keyed pending map), the write requires a
  matching handle extension AND a live tab, and a failed re-pick
  drops the old handle before the download fallback.
- Discarding an unsaved tab leaked its handle, and a serialization
  still in flight could then overwrite the real file the user just
  declined to save. Discard drops the handle; a tab-gone completion
  downloads instead of writing.
- Overlapping saves had no guard: a wasm in-flight slot refuses a
  second save with the same localized message, cleared on every exit
  path.

Paste: pasted_image_name built its timestamp from Date::new(0) — a
CONSTANT name, so every paste overwrote every earlier paste's image.
Real clock, milliseconds, and a collision-suffixed unique name now.

Capture: a web capture in a hidden tab, or one with nothing redrawing,
could hold the automation slot forever. It fails fast when the page is
hidden and gives up after 5 s, on main's frame-parked capture.

The XREF manager reports registry-loaded xrefs with their size on the
web instead of NotFound.

Left out: the always-warm print snapshot (a frames subscription that
emits a message on every frame keeps the app redrawing at the display
rate), so the browser-menu print wiring from the previous commit goes
too — its beforeprint readback printed blank pages on WebGL2. The
merge-residue hunks (re-added XREF bind tests, snap endpoint map and
3D-snap gate) belong to code main already has in its own form.

(cherry picked from commit da950460)
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent 835e8d469f
commit 1c0963b68d
8 files changed
+382 -90

No files matched your search

+46 -20
View File
@@ -131,6 +131,12 @@ pub(super) struct State {
/// Whether the parked capture actually waited for a redraw (vs. the
/// anti-hang timer). Reported in the capture result as `waited_for_frame`.
pub(super) capture_after_redraw: bool,
/// Web: when a parked capture gives up. Only a presented frame can be
/// read back on wasm, and a hidden or idle tab may never present one, so
/// the timer tick fails the capture past this instant instead of letting
/// it hold the control slot forever.
#[cfg(target_arch = "wasm32")]
pub(super) capture_deadline: Option<iced::time::Instant>,
}
/// The pending half of an interactive `user_select` operation (`interactive`
@@ -1212,6 +1218,11 @@ impl OpenCADStudio {
// rendered frame (or the timer fallback) so geometry changes are
// visible rather than returning a stale frame.
self.control.capture_frame_pending = true;
#[cfg(target_arch = "wasm32")]
{
self.control.capture_deadline =
Some(iced::time::Instant::now() + std::time::Duration::from_secs(5));
}
Task::none()
}
"stop" => {
@@ -1648,30 +1659,45 @@ impl OpenCADStudio {
// browser clears the WebGL buffer — so wait for a real frame.
#[cfg(target_arch = "wasm32")]
{
if !redrew {
let expired = self
.control
.capture_deadline
.is_some_and(|deadline| iced::time::Instant::now() >= deadline);
if !redrew && !expired && crate::sys::page_visible() {
return Task::none();
}
self.control.capture_frame_pending = false;
self.control.capture_after_redraw = true;
return Task::done(Message::ControlScreenshot(path, crate::sys::capture_canvas()));
}
self.control.capture_frame_pending = false;
self.control.capture_after_redraw = redrew;
let Some(window) = self.main_window else {
return Task::done(Message::ControlScreenshot(path, None));
};
// A minimized window has a 0x0 surface and the renderer panics reading
// it back, so report instead of capturing.
iced::window::size(window).then(move |size| {
let path = path.clone();
if size.width <= 0.0 || size.height <= 0.0 {
Task::done(Message::ControlScreenshot(path, None))
self.control.capture_deadline = None;
self.control.capture_after_redraw = redrew;
// No frame to read (hidden tab or nothing redrawing): report the
// capture as failed rather than hand back a blank canvas.
let screenshot = if redrew {
crate::sys::capture_canvas()
} else {
iced::window::screenshot(window).map(move |screenshot| {
Message::ControlScreenshot(path.clone(), Some(screenshot))
})
}
})
None
};
Task::done(Message::ControlScreenshot(path, screenshot))
}
#[cfg(not(target_arch = "wasm32"))]
{
self.control.capture_frame_pending = false;
self.control.capture_after_redraw = redrew;
let Some(window) = self.main_window else {
return Task::done(Message::ControlScreenshot(path, None));
};
// A minimized window has a 0x0 surface and the renderer panics
// reading it back, so report instead of capturing.
iced::window::size(window).then(move |size| {
let path = path.clone();
if size.width <= 0.0 || size.height <= 0.0 {
Task::done(Message::ControlScreenshot(path, None))
} else {
iced::window::screenshot(window).map(move |screenshot| {
Message::ControlScreenshot(path.clone(), Some(screenshot))
})
}
})
}
}
}
mod actions;
+23
View File
@@ -198,6 +198,14 @@ impl OpenCADStudio {
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
return Task::batch([close_win, self.continue_tab_close_queue()]);
};
// …and, on the web, its retained in-place save handle — the
// same hook on_tab_close runs: the user declined the save,
// so the picked file must never receive a later write, and
// the next tab to reuse this id must start with a fresh
// pick instead of silently writing over the discarded
// drawing's file.
#[cfg(target_arch = "wasm32")]
crate::io::fs_access::drop_handle(tab_id);
// Discarded — drop this tab's autosave recovery copy.
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(idx));
@@ -319,6 +327,21 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
{
// Same guard the native half runs against active_save_jobs
// above: while a web save is between capture and completion,
// refuse a second one (the window is seconds — refuse, don't
// queue) instead of letting two serializer rounds race for the
// same tab's bookkeeping.
if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() {
if self.tabs.iter().any(|tab| tab.id == busy_tab) {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
return Task::none();
}
// Stale marker (its tab closed before settling): clear it
// rather than block this save forever.
crate::io::fs_access::end_web_save(busy_tab);
}
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(tab_id)) => {
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
+69 -11
View File
@@ -2718,6 +2718,9 @@ impl OpenCADStudio {
) -> Task<Message> {
self.thumbnail_capture_clean = false;
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
// The tab closed while its capture was queued: the save is over
// (no serialization follows), so release the in-flight slot.
crate::io::fs_access::end_web_save(tab_id);
return Task::none();
};
let preview = screenshot.as_ref().and_then(|screenshot| {
@@ -2765,11 +2768,12 @@ impl OpenCADStudio {
}
/// Web SAVE completion: the serializer worker (or inline fallback) has the
/// drawing bytes. When the tab holds a File System Access handle the
/// bytes go to it in place (createWritable needs no user gesture, so the
/// drawing bytes. When the tab still exists AND holds a File System
/// Access handle picked for the format these bytes were serialized as,
/// they go to it in place (createWritable needs no user gesture, so the
/// write may happen long after the pick); completion continues in
/// `on_web_save_written`. Otherwise the legacy download fires — the only
/// artifact of the save, launched even if the tab was closed while
/// `on_web_save_written`. Anything else takes the legacy download — the
/// only artifact of the save, launched even if the tab was closed while
/// serialization was in flight.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_serialized(
@@ -2788,15 +2792,37 @@ impl OpenCADStudio {
Err(error) => {
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
crate::io::fs_access::end_web_save(tab_id);
return self.finish_web_save(false);
}
};
// In-place write through the handle picked at the confirm gesture
// (or an earlier one). The bookkeeping runs at write completion
// (WebSaveWritten) so a failed write cannot clear the dirty flag;
// the bytes ride back with the completion so the OPFS recent store
// gets the same copy the download path uses.
if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) {
// In-place write eligibility, all three required:
// * the tab still exists — a tab that closed while serialization ran
// means the user declined this save (discard/close), and the
// legacy contract for closed tabs is download-only;
// * a handle is held AND it was picked for the format these bytes
// were serialized for (the pending ext recorded at capture time):
// a Save As whose re-pick failed falls back to the download, and
// the stale handle from the previous format must not receive the
// fallback bytes (a .dwg file must never silently hold DXF).
let tab_exists = self.tabs.iter().position(|tab| tab.id == tab_id).is_some();
let target_ext = crate::io::fs_access::take_pending_save_ext(tab_id);
let write_in_place = tab_exists
&& crate::io::fs_access::write_or_download(
crate::io::fs_access::handle_ext(tab_id).as_deref(),
target_ext.as_deref(),
);
if !write_in_place {
// Whatever handle is held is stale for these bytes (wrong
// format, unverifiable format, or a tab that declined the
// save): drop it so the next save re-picks instead of hitting
// the same mismatch. No handle held: the drop is a no-op.
crate::io::fs_access::drop_handle(tab_id);
} else if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) {
// The bookkeeping runs at write completion (WebSaveWritten) so a
// failed write cannot clear the dirty flag; the bytes ride back
// with the completion message so the OPFS recent store gets the
// same copy the download path uses.
let download_name = picked_name.clone();
return Task::perform(
async move {
@@ -2896,6 +2922,9 @@ impl OpenCADStudio {
camera_generation: u64,
bytes: Vec<u8>,
) -> Task<Message> {
// The save settled (download or in-place write): release the
// in-flight slot the on_save_file guard refuses against.
crate::io::fs_access::end_web_save(tab_id);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
@@ -3682,6 +3711,20 @@ impl OpenCADStudio {
// once after the destination/version is known.
#[cfg(target_arch = "wasm32")]
{
// The web counterpart of native's active_save_jobs guard: one
// capture → serialize → write/download chain at a time. The
// window is seconds, so refuse rather than queue — same
// localized message the native flow shows.
if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() {
if self.tabs.iter().any(|tab| tab.id == busy_tab) {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
return Task::none();
}
// Stale marker (its tab closed before the save could
// settle): clear it instead of blocking every later save.
crate::io::fs_access::end_web_save(busy_tab);
}
self.sync_view_state_for_save(i);
self.stamp_header_sysvars(i);
}
@@ -3911,6 +3954,12 @@ impl OpenCADStudio {
ext: String,
version: codec::DxfVersion,
) -> Task<Message> {
// The save is now committed to this tab and this format: mark it
// in flight (the web counterpart of native's active_save_jobs —
// on_save_file refuses a second save while the slot is held) and
// record the intended ext, which the completion half re-checks
// against the retained handle before writing.
crate::io::fs_access::begin_web_save(tab_id, &ext);
let bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID);
let Some(bounds) = bounds else {
return Task::done(Message::WebSaveScreenshot {
@@ -3939,7 +3988,9 @@ impl OpenCADStudio {
/// any previous one — Save As re-picks); `Ok(None)` (dismissed picker)
/// cancels the save like a native save-dialog cancel; `Err` keeps the
/// save alive through the legacy download, with a note that the copy
/// was downloaded.
/// was downloaded — after dropping any previously retained handle,
/// which was picked for the old format and must not receive the
/// fallback bytes.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_handle_picked(
&mut self,
@@ -3965,6 +4016,13 @@ impl OpenCADStudio {
Task::none()
}
Err(error) => {
// The picker failed and the download fallback is about to
// run with bytes serialized for `ext` — but any handle still
// held was picked for (possibly) the OTHER format (Save As
// to DXF on a .dwg handle, …). Drop it now so the stale
// handle cannot receive the fallback bytes when they land;
// the next save re-picks.
crate::io::fs_access::drop_handle(tab_id);
self.command_line.push_error(
crate::tf!(
"Save destination picker failed: {error}; downloading a copy instead"