forked from 1b-agent/opencadstudio-upstream
fix: close the adversarial findings on the save/paste wave
Ported fromda950460(#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 commitda950460)
This commit is contained in:
1 parent
835e8d469f
commit
1c0963b68d
8 files changed
+382
-90
No files matched your search
+112
-4
@@ -82,6 +82,20 @@ fn exts_match(a: &str, b: &str) -> bool {
|
||||
strip(a) == strip(b)
|
||||
}
|
||||
|
||||
/// Whether bytes serialized for `target_ext` may be written through a
|
||||
/// handle picked for `handle_ext` (`None` = no handle held; a `None`
|
||||
/// target = the intended format is unknown). This is the completion-half
|
||||
/// counterpart of `save_route`: routing happens at the confirm gesture,
|
||||
/// but the bytes arrive later — possibly serialized for a *different*
|
||||
/// format than the retained handle (a Save As whose re-pick failed falls
|
||||
/// back to the download while the old handle stays in the registry), and
|
||||
/// a `.dwg` handle must never receive DXF bytes (or vice versa) just
|
||||
/// because it is still held.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
|
||||
pub fn write_or_download(handle_ext: Option<&str>, target_ext: Option<&str>) -> bool {
|
||||
matches!((handle_ext, target_ext), (Some(handle), Some(target)) if exts_match(handle, target))
|
||||
}
|
||||
|
||||
/// `showSaveFilePicker` rejects with a `DOMException` named "AbortError"
|
||||
/// when the user dismisses the dialog. That name check is the whole cancel
|
||||
/// classification — kept pure so it compiles and tests on every target.
|
||||
@@ -128,10 +142,25 @@ mod browser {
|
||||
/// not an App field or a Message payload — owns the handles because
|
||||
/// iced requires `Message: Send` even on wasm, and a JsValue-backed
|
||||
/// handle is not Send. (Same pattern as web_recent's OPEN_STORES.)
|
||||
/// Closing a tab, and a failed write, drop its entry; Save As
|
||||
/// replaces it.
|
||||
/// Closing a tab, a failed write, a failed re-pick and a format
|
||||
/// mismatch drop its entry; Save As replaces it.
|
||||
static SAVE_HANDLES: RefCell<HashMap<u64, SaveHandle>> =
|
||||
RefCell::new(HashMap::new());
|
||||
|
||||
/// The web save currently between `web_save_capture` and its
|
||||
/// completion (serialized → written/downloaded) — the web
|
||||
/// counterpart of native's `active_save_jobs` guard, kept here for
|
||||
/// the same Send reason as the handles. One slot: a second save
|
||||
/// while one is in flight is refused, not queued (the window is
|
||||
/// seconds).
|
||||
static WEB_SAVE_IN_FLIGHT: RefCell<Option<u64>> = RefCell::new(None);
|
||||
|
||||
/// The format each tab's in-flight save is being serialized for —
|
||||
/// the `target_ext` input of `write_or_download`, recorded at
|
||||
/// capture time because the completion messages cannot carry it
|
||||
/// (the bytes may outlive several rounds of tab state).
|
||||
static PENDING_SAVE_EXTS: RefCell<HashMap<u64, String>> =
|
||||
RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// The extension the tab's retained handle was picked for, if any —
|
||||
@@ -161,6 +190,49 @@ mod browser {
|
||||
});
|
||||
}
|
||||
|
||||
/// Mark `tab_id`'s save as in flight and remember the format its bytes
|
||||
/// will be serialized for. Called at `web_save_capture` — the moment a
|
||||
/// save is committed to a tab and a format — so `on_save_file` can
|
||||
/// refuse a second concurrent save and the completion half can check
|
||||
/// the bytes against the retained handle (`write_or_download`).
|
||||
pub fn begin_web_save(tab_id: u64, ext: &str) {
|
||||
PENDING_SAVE_EXTS.with(|exts| {
|
||||
exts.borrow_mut().insert(
|
||||
tab_id,
|
||||
ext.trim_start_matches('.').to_ascii_lowercase(),
|
||||
);
|
||||
});
|
||||
WEB_SAVE_IN_FLIGHT.with(|in_flight| in_flight.borrow_mut().replace(tab_id));
|
||||
}
|
||||
|
||||
/// End `tab_id`'s in-flight save — completion, error or cancel. The
|
||||
/// single slot is only released when it still names this tab: a newer
|
||||
/// save that took the slot over must not have its lifecycle cut short.
|
||||
pub fn end_web_save(tab_id: u64) {
|
||||
WEB_SAVE_IN_FLIGHT.with(|in_flight| {
|
||||
let mut slot = in_flight.borrow_mut();
|
||||
if *slot == Some(tab_id) {
|
||||
*slot = None;
|
||||
}
|
||||
});
|
||||
PENDING_SAVE_EXTS.with(|exts| {
|
||||
exts.borrow_mut().remove(&tab_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// The tab whose web save is between capture and completion, if any —
|
||||
/// what the save entry points refuse against while it is set.
|
||||
pub fn web_save_in_flight() -> Option<u64> {
|
||||
WEB_SAVE_IN_FLIGHT.with(|in_flight| *in_flight.borrow())
|
||||
}
|
||||
|
||||
/// Take the format `tab_id`'s serialized bytes were intended for —
|
||||
/// consumed (once) by the completion handler to decide
|
||||
/// write-through-handle vs download via `write_or_download`.
|
||||
pub fn take_pending_save_ext(tab_id: u64) -> Option<String> {
|
||||
PENDING_SAVE_EXTS.with(|exts| exts.borrow_mut().remove(&tab_id))
|
||||
}
|
||||
|
||||
/// Feature-detect `showSaveFilePicker` — never assume it: Firefox and
|
||||
/// Safari keep the download flow precisely because this returns false.
|
||||
pub fn supported() -> bool {
|
||||
@@ -314,12 +386,13 @@ mod browser {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::{
|
||||
drop_handle, handle_ext, handle_name, pick_save_handle, supported, write_handle,
|
||||
begin_web_save, drop_handle, end_web_save, handle_ext, handle_name, pick_save_handle,
|
||||
supported, take_pending_save_ext, web_save_in_flight, write_handle,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_cancel_error_name, save_route, SaveRoute};
|
||||
use super::{is_cancel_error_name, save_route, write_or_download, SaveRoute};
|
||||
|
||||
/// Firefox/Safari: no showSaveFilePicker — every save stays a download,
|
||||
/// whatever handle state the tab happens to hold.
|
||||
@@ -374,4 +447,39 @@ mod tests {
|
||||
assert!(!is_cancel_error_name("TypeError"));
|
||||
assert!(!is_cancel_error_name(""));
|
||||
}
|
||||
|
||||
/// The completion-half decision: bytes serialized for the same format
|
||||
/// the retained handle was picked for may be written through it.
|
||||
#[test]
|
||||
fn matching_serialized_ext_writes_in_place() {
|
||||
assert!(write_or_download(Some("dwg"), Some("dwg")));
|
||||
assert!(write_or_download(Some("dxf"), Some("dxf")));
|
||||
assert!(write_or_download(Some(".DWG"), Some("dwg")));
|
||||
assert!(write_or_download(Some("dxf"), Some(".DXF")));
|
||||
}
|
||||
|
||||
/// The BLOCKER scenario: a Save As whose re-pick failed continues as a
|
||||
/// download, but the OLD handle (picked for the other format) is still
|
||||
/// in the registry — those bytes must not be written through it.
|
||||
#[test]
|
||||
fn stale_handle_of_other_format_downloads() {
|
||||
assert!(!write_or_download(Some("dwg"), Some("dxf")));
|
||||
assert!(!write_or_download(Some("dxf"), Some("dwg")));
|
||||
}
|
||||
|
||||
/// No handle held (Firefox/Safari, first save, dropped handle) — the
|
||||
/// legacy download carries the bytes.
|
||||
#[test]
|
||||
fn no_handle_downloads() {
|
||||
assert!(!write_or_download(None, Some("dwg")));
|
||||
assert!(!write_or_download(None, Some("dxf")));
|
||||
}
|
||||
|
||||
/// Unknown intended format (no pending ext recorded for the save): an
|
||||
/// unverifiable handle must not receive the bytes — download.
|
||||
#[test]
|
||||
fn unknown_target_ext_downloads() {
|
||||
assert!(!write_or_download(Some("dwg"), None));
|
||||
assert!(!write_or_download(None, None));
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -1405,12 +1405,25 @@ pub fn collect_entries_with_prev(
|
||||
/// The filesystem stat is desktop-only by `cfg` (not a runtime check): the
|
||||
/// wasm binary contains no `std::fs::metadata` call at all, so a web
|
||||
/// session can never block on — or fail from — a host-filesystem probe.
|
||||
/// Path-based web references resolve through the file-handle loader and
|
||||
/// simply stay `NotFound` when unresolvable.
|
||||
/// Path-based web references resolve through the file-handle loader: a
|
||||
/// source present in that registry reports Loaded here, and only truly
|
||||
/// unregistered ones stay `NotFound`.
|
||||
fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) {
|
||||
if raw.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Web (and the native tests exercising it): `resolve_path` probes the
|
||||
// filesystem, which the browser does not have — consult the source
|
||||
// registry instead. A registered drawing IS loaded; its byte length
|
||||
// comes free from the in-memory bytes (no read), and there is no
|
||||
// mtime to report.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
if let Some(key) = find_registered_source(raw) {
|
||||
entry.size_bytes = registered_bytes(&key).map(|b| b.len() as u64);
|
||||
entry.found_at = Some(key);
|
||||
entry.status = RefStatus::Loaded;
|
||||
return;
|
||||
}
|
||||
let Some(found) = resolve_path(raw, base_dir) else {
|
||||
return;
|
||||
};
|
||||
@@ -2005,6 +2018,8 @@ fn bind_dwg(
|
||||
.find(|b| b.handle == br_handle)
|
||||
.map(|b| b.xref_path.clone())
|
||||
.unwrap_or_default();
|
||||
// Native-only path: `resolve_path` reads the real filesystem, so DWG
|
||||
// bind stays desktop-only (the web has no fs to load a bind source from).
|
||||
let Some(found) = resolve_path(&saved, base_dir) else {
|
||||
return Err(bind_failed(name, "file not found"));
|
||||
};
|
||||
@@ -2181,6 +2196,8 @@ fn collect_bind_images(
|
||||
let mut planned: HashMap<String, String> = HashMap::default();
|
||||
let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new();
|
||||
for (handle, saved) in &defs {
|
||||
// Native-only path: `resolve_path` stats the real filesystem (and
|
||||
// the copy below reads it), so image COLLECT stays desktop-only.
|
||||
let Some(resolved) = resolve_path(saved, source_dir) else {
|
||||
return Err(bind_failed(ref_name, format!("image not found: {saved}")));
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user