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

+101 -15
View File
@@ -297,6 +297,17 @@ pub fn capture_canvas() -> Option<iced::window::Screenshot> {
))
}
/// Web: is the page visible? `requestAnimationFrame` — and therefore the
/// frames subscription and any canvas readback — never runs while
/// `document.hidden`, so the control `capture` op refuses to wait for a frame
/// that could never come.
#[cfg(target_arch = "wasm32")]
pub fn page_visible() -> bool {
!web_sys::window()
.and_then(|window| window.document())
.is_some_and(|document| document.hidden())
}
/// Web: read text from the system clipboard via the async Clipboard API.
/// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is
/// async + permission-gated), so the editor paste paths use this instead. The
@@ -340,7 +351,8 @@ fn clipboard_image_extensions_cover_the_paste_flavors() {
/// Web: read an image from the system clipboard via the async Clipboard API
/// (`navigator.clipboard.read()`), used when a paste found no text. Returns
/// the registry name (`pasted-YYYYMMDD-HHMMSS.<ext>`) plus the image bytes,
/// the registry name (`pasted-YYYYMMDD-HHMMSS-mmm[-N].<ext>`, unique against
/// the registry) plus the image bytes,
/// or `None` when the clipboard holds no image item or the read is denied.
/// Like `read_clipboard_text`, the Ctrl+V keypress that reaches it is a user
/// gesture, which is what makes the permission-gated read allowed at all.
@@ -380,29 +392,103 @@ pub async fn read_clipboard_image() -> Option<(String, Vec<u8>)> {
if bytes.is_empty() {
return None;
}
return Some((pasted_image_name(&mime_type), bytes));
let name = pasted_image_name(&mime_type);
// Register under the chosen name BEFORE returning: no other wasm
// code can run between the uniqueness check and this insert (one
// thread), so a second paste landing in the same millisecond sees
// this name as taken and takes a `-2` suffix instead of overwriting
// these bytes. The paste handler's own `register_source` call with
// the same name is then an idempotent overwrite of identical bytes.
crate::scene::model::image_model::register_source(
&name,
std::sync::Arc::new(bytes.clone()),
);
return Some((name, bytes));
}
None
}
/// Registry name for a pasted clipboard image: `pasted-YYYYMMDD-HHMMSS.ext`
/// stamped from the wall clock (local time, the same clock a screenshot file
/// name uses) with the extension derived from the MIME type.
/// Registry name for a pasted clipboard image:
/// `pasted-YYYYMMDD-HHMMSS-mmm.ext` stamped from the wall clock (local time,
/// the same clock a screenshot file name uses) at millisecond precision, with
/// the extension derived from the MIME type. Milliseconds alone don't
/// guarantee uniqueness (two pastes inside one ms), so the name is pushed
/// through [`unique_pasted_image_name`] against the live registry — a reused
/// key would overwrite the earlier paste's bytes (the registry is last-wins)
/// and make its entity display the newer pixels.
#[cfg(target_arch = "wasm32")]
fn pasted_image_name(mime_type: &str) -> String {
let now = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0));
format!(
"pasted-{:04}{:02}{:02}-{:02}{:02}{:02}.{}",
now.get_full_year(),
now.get_month() + 1,
now.get_date(),
now.get_hours(),
now.get_minutes(),
now.get_seconds(),
clipboard_image_extension(mime_type)
let now = js_sys::Date::new_0();
unique_pasted_image_name(
&format!(
"pasted-{:04}{:02}{:02}-{:02}{:02}{:02}-{:03}",
now.get_full_year(),
now.get_month() + 1,
now.get_date(),
now.get_hours(),
now.get_minutes(),
now.get_seconds(),
(now.get_time() as u64) % 1000,
),
clipboard_image_extension(mime_type),
crate::scene::model::image_model::has_source,
)
}
/// Turn `base` + `ext` into a registry name not taken yet: the bare
/// `base.ext` first, then `-2`, `-3`, … inserted before the extension while
/// the candidate already names a registered source. The taken-check is
/// injectable so this — the part worth locking with tests — runs natively;
/// wasm passes [`crate::scene::model::image_model::has_source`].
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // tested everywhere, used on wasm
pub(crate) fn unique_pasted_image_name(
base: &str,
ext: &str,
taken: impl Fn(&str) -> bool,
) -> String {
let first = format!("{base}.{ext}");
if !taken(&first) {
return first;
}
for suffix in 2u64.. {
let candidate = format!("{base}-{suffix}.{ext}");
if !taken(&candidate) {
return candidate;
}
}
unreachable!("an unbounded suffix space always yields a free name")
}
#[test]
fn pasted_image_names_never_reuse_a_taken_registry_key() {
let mut taken: Vec<String> = Vec::new();
let claim = |taken: &mut Vec<String>| {
let name = unique_pasted_image_name("pasted-20260915-101500-123", "png", |candidate| {
taken.iter().any(|used| used == candidate)
});
taken.push(name.clone());
name
};
// Fresh registry: the bare millisecond-stamped name is used as-is.
assert_eq!(claim(&mut taken), "pasted-20260915-101500-123.png");
// A second paste inside the same millisecond suffixes instead of
// overwriting — each name below must differ from every earlier one.
assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-2.png");
assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-3.png");
// The extension is part of the key: a jpg landing in the same
// millisecond does not collide with the pngs above.
assert_eq!(
unique_pasted_image_name("pasted-20260915-101500-123", "jpg", |candidate| {
taken.iter().any(|used| used == candidate)
}),
"pasted-20260915-101500-123.jpg"
);
// Freed suffixes are reused in order: with `-2` unregistered again, the
// next paste takes `-2` back before walking further.
taken.remove(1);
assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-2.png");
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
extern "C" {