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
+46
-20
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
+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}")));
|
||||
};
|
||||
|
||||
@@ -574,6 +574,18 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a source registered under exactly this reference? The web paste path
|
||||
/// uses it to keep its generated `pasted-…` names unique: the registry is
|
||||
/// last-wins, so a reused name would silently re-point every earlier paste's
|
||||
/// entity at the newest pixels. Exact key match — generated names embed their
|
||||
/// own extension and are all lower-case digits.
|
||||
pub fn has_source(path: &str) -> bool {
|
||||
source_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.contains_key(path)
|
||||
}
|
||||
|
||||
/// Every reference with bytes currently registered (picked or dropped).
|
||||
/// Used after a web open settles to catch images registered while the load —
|
||||
/// which builds the raster models — was already running.
|
||||
|
||||
+101
-15
@@ -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" {
|
||||
|
||||
@@ -38,16 +38,6 @@
|
||||
animation: ocs-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes ocs-spin { to { transform: rotate(360deg); } }
|
||||
/* Browser-menu print support: hidden on screen, the only thing shown on
|
||||
paper. beforeprint snapshots the WebGL canvas into it (below), so the
|
||||
printout is the drawing instead of a blank page — WebGL canvases do
|
||||
not composite into the print surface on their own. */
|
||||
#print-snapshot { display: none; }
|
||||
@media print {
|
||||
body > *:not(#print-snapshot) { display: none !important; }
|
||||
html, body { overflow: visible; height: auto; background: #fff; }
|
||||
#print-snapshot { display: block; max-width: 100%; height: auto; }
|
||||
}
|
||||
</style>
|
||||
<link data-trunk rel="rust" data-bin="OpenCADStudio" />
|
||||
<link data-trunk rel="copy-dir" href="web/fonts" />
|
||||
@@ -66,8 +56,6 @@
|
||||
<div class="title">Open <span>CAD</span> Studio</div>
|
||||
<div class="sub">Loading…</div>
|
||||
</div>
|
||||
<!-- Print-only copy of the canvas; filled by beforeprint below. -->
|
||||
<img id="print-snapshot" alt="" />
|
||||
<script src="ocs-control.js" defer></script>
|
||||
<script>
|
||||
// Offline support: register the service worker. It is copied next to
|
||||
@@ -84,32 +72,6 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Print support. The drawing lives in a WebGL canvas, which browsers
|
||||
// rasterize as blank on the print surface. beforeprint therefore copies
|
||||
// it onto a 2D canvas (the same drawImage readback the Rust side's
|
||||
// sys::capture_canvas uses — no Rust state involved, the canvas is
|
||||
// located from the DOM) and shows the snapshot in the print-only
|
||||
// <img>; afterprint drops it again to free the PNG.
|
||||
(() => {
|
||||
const snapshot = document.getElementById("print-snapshot");
|
||||
if (!snapshot) return;
|
||||
window.addEventListener("beforeprint", () => {
|
||||
const canvas = document.querySelector("canvas");
|
||||
if (!canvas || !canvas.width || !canvas.height) return;
|
||||
const copy = document.createElement("canvas");
|
||||
copy.width = canvas.width;
|
||||
copy.height = canvas.height;
|
||||
const context = copy.getContext("2d");
|
||||
if (!context) return;
|
||||
context.drawImage(canvas, 0, 0);
|
||||
snapshot.src = copy.toDataURL("image/png");
|
||||
});
|
||||
window.addEventListener("afterprint", () => {
|
||||
snapshot.removeAttribute("src");
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const splash = document.getElementById("loading");
|
||||
|
||||
Reference in new issue
Block a user