forked from 1b-agent/opencadstudio-upstream
web: worker-route WBLOCK/SAVEALL/ARCHIVE; close the follow-up review
WBLOCK, SAVEALL, and web ARCHIVE now serialize through the Web Worker like SAVE/AUTOSAVE: prepare on the main thread, writer pass on the worker, inline fallback on any worker failure. SAVEALL clears each tab's dirty flag under the same unchanged-tab race guard the single save uses; ARCHIVE assembles the ZIP in the completion handler so the transmittal matches the snapshot taken at command time. The review of the previous fix commit found the collision refusal still REPLACED the registry bytes — the exact silent retargeting it was meant to prevent. The refusal now keeps the old registration, the extraction settings picker reports refused picks in the wizard's error slot (previously silent, and the stale collision flag could surface as a wrong-file warning on an unrelated browse), and the xref registry's same-name ambiguity is documented as a browser fundamental (native distinguishes by folder): newest registration wins consistently for resolution, ARCHIVE, and XRELOAD — matching what the drawing displays. Also: the Quit pending-close survives a successful web tab-save (continue the quit instead of dropping it), the last ZIP64 sentinel (central size) is rejected like the other four, and the new behaviors gained regression tests (collision keeps old bytes, registry eviction, including the deadlock fix for the guard-scoping that test exposed).
This commit is contained in:
1 parent
6f429f2416
commit
b635d56c00
7 files changed
+415
-83
No files matched your search
@@ -79,14 +79,18 @@ pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) -> bool {
|
|||||||
let mut cache = picked_sources()
|
let mut cache = picked_sources()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
let replaced = cache
|
let Some(old) = cache.get(path) else {
|
||||||
.get(path)
|
cache.insert(path.to_string(), bytes);
|
||||||
.is_some_and(|old| old.as_slice() != bytes.as_slice());
|
return true;
|
||||||
if replaced {
|
};
|
||||||
take_last_collision().replace(file_name_of(path));
|
if old.as_slice() == bytes.as_slice() {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
cache.insert(path.to_string(), bytes);
|
// Refuse AND keep the old bytes: overwriting would silently retarget
|
||||||
!replaced
|
// every reference reading this name. The name is remembered so the
|
||||||
|
// browse handlers can tell the user why their pick did nothing.
|
||||||
|
take_last_collision().replace(file_name_of(path));
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name whose re-pick was refused because different bytes are already
|
/// The name whose re-pick was refused because different bytes are already
|
||||||
@@ -1141,7 +1145,7 @@ impl OpenCADStudio {
|
|||||||
if let Some(name) = take_last_collision().take() {
|
if let Some(name) = take_last_collision().take() {
|
||||||
self.command_line.push_warning(
|
self.command_line.push_warning(
|
||||||
crate::tf!(
|
crate::tf!(
|
||||||
"\"{name}\" is already loaded from another folder with different contents; re-picking it was ignored to keep the existing link."
|
"\"{name}\" is already loaded from another folder with different contents; re-picking it was ignored to keep the existing link."
|
||||||
)
|
)
|
||||||
.as_ref(),
|
.as_ref(),
|
||||||
);
|
);
|
||||||
@@ -1533,6 +1537,13 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_data_extraction_browse_settings_result(&mut self, path: Option<PathBuf>) -> Task<Message> {
|
pub(super) fn on_data_extraction_browse_settings_result(&mut self, path: Option<PathBuf>) -> Task<Message> {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Some(name) = take_last_collision().take() {
|
||||||
|
self.data_extraction.error = crate::tf!(
|
||||||
|
"\"{name}\" is already loaded with different contents; the existing file was kept."
|
||||||
|
)
|
||||||
|
.into_owned();
|
||||||
|
}
|
||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
self.data_extraction.settings_path = path.to_string_lossy().into_owned();
|
self.data_extraction.settings_path = path.to_string_lossy().into_owned();
|
||||||
if self.data_extraction.begin != ExtractionBegin::New {
|
if self.data_extraction.begin != ExtractionBegin::New {
|
||||||
@@ -1721,6 +1732,23 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// A refused same-name re-pick must KEEP the old bytes: overwriting
|
||||||
|
/// would silently retarget every reference reading that name.
|
||||||
|
#[test]
|
||||||
|
fn collision_refusal_keeps_the_registered_bytes() {
|
||||||
|
register_picked_source("a.csv", Arc::new(b"old".to_vec()));
|
||||||
|
let fresh = register_picked_source("a.csv", Arc::new(b"new".to_vec()));
|
||||||
|
assert!(!fresh, "differing bytes under a live name are refused");
|
||||||
|
assert_eq!(
|
||||||
|
picked_source_bytes("a.csv").as_ref().map(|b| b.as_slice()),
|
||||||
|
Some(&b"old"[..]),
|
||||||
|
"the old registration survives the refusal"
|
||||||
|
);
|
||||||
|
// Identical bytes are a no-op accept.
|
||||||
|
assert!(register_picked_source("a.csv", Arc::new(b"old".to_vec())));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+71
-21
@@ -64,8 +64,9 @@ impl OpenCADStudio {
|
|||||||
{
|
{
|
||||||
// Web: every dirty saved tab is serialized and downloaded
|
// Web: every dirty saved tab is serialized and downloaded
|
||||||
// in one pass — see `saveall_web` for the flow.
|
// in one pass — see `saveall_web` for the flow.
|
||||||
self.saveall_web();
|
return Some(self.saveall_web());
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
return Some(Task::none());
|
return Some(Task::none());
|
||||||
}
|
}
|
||||||
"SAVEAS" => return Some(Task::done(Message::SaveAs)),
|
"SAVEAS" => return Some(Task::done(Message::SaveAs)),
|
||||||
@@ -222,7 +223,13 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
self.archive_web(i, cmd);
|
{
|
||||||
|
// The drawing serialization runs on the serializer
|
||||||
|
// worker; the ZIP assembly + download continue in the
|
||||||
|
// completion message (`archive_web` below).
|
||||||
|
let task = self.archive_web(i, cmd);
|
||||||
|
return Some(Task::batch([task, self.finish_dispatch(cmd)]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"EXIT" | "QUIT" => {
|
"EXIT" | "QUIT" => {
|
||||||
@@ -519,14 +526,14 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
|
|||||||
// silently dropped, the web counterpart of native's missing-file skip.
|
// silently dropped, the web counterpart of native's missing-file skip.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
impl OpenCADStudio {
|
impl OpenCADStudio {
|
||||||
pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) {
|
pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) -> Task<Message> {
|
||||||
use crate::io::zip::ZipEntry;
|
use crate::io::zip::ZipEntry;
|
||||||
|
|
||||||
let Some(src) = self.tabs[i].current_path.clone() else {
|
let Some(src) = self.tabs[i].current_path.clone() else {
|
||||||
self.command_line.push_error(
|
self.command_line.push_error(
|
||||||
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref(),
|
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref(),
|
||||||
);
|
);
|
||||||
return;
|
return Task::none();
|
||||||
};
|
};
|
||||||
let stem = src
|
let stem = src
|
||||||
.file_stem()
|
.file_stem()
|
||||||
@@ -550,19 +557,12 @@ impl OpenCADStudio {
|
|||||||
self.stamp_header_sysvars(i);
|
self.stamp_header_sysvars(i);
|
||||||
self.sync_solid_models_for_save(i);
|
self.sync_solid_models_for_save(i);
|
||||||
self.tabs[i].scene.sync_native_parametric_graph();
|
self.tabs[i].scene.sync_native_parametric_graph();
|
||||||
// The drawing itself: the same serialization a web Save performs
|
|
||||||
// (native copies the last-saved file; the web build has none, so the
|
// The dependency set (xrefs, raster images, PDF underlays, plot
|
||||||
// current document is re-serialized).
|
// tables) is collected here, at command time — cheap in-memory
|
||||||
let drawing = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) {
|
// registry reads — so the transmittal matches the drawing snapshot
|
||||||
Ok(bytes) => bytes,
|
// serialized below even if the tab changes while the writer runs.
|
||||||
Err(e) => {
|
let mut deps: Vec<ZipEntry> = Vec::new();
|
||||||
self.command_line.push_error(
|
|
||||||
crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut entries: Vec<ZipEntry> = vec![ZipEntry::new(filename, drawing)];
|
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// Xrefs — the block-record walk native copies. Opened, dropped,
|
// Xrefs — the block-record walk native copies. Opened, dropped,
|
||||||
@@ -574,7 +574,7 @@ impl OpenCADStudio {
|
|||||||
match crate::io::xref::find_registered_source(&br.xref_path)
|
match crate::io::xref::find_registered_source(&br.xref_path)
|
||||||
.and_then(|key| crate::io::xref::registered_bytes(&key))
|
.and_then(|key| crate::io::xref::registered_bytes(&key))
|
||||||
{
|
{
|
||||||
Some(bytes) => push_entry(&mut entries, name, (*bytes).clone()),
|
Some(bytes) => push_entry(&mut deps, name, (*bytes).clone()),
|
||||||
None => push_skipped(&mut skipped, &name),
|
None => push_skipped(&mut skipped, &name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,7 +589,7 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
match crate::scene::model::image_model::registered_source_bytes(&img.file_path) {
|
match crate::scene::model::image_model::registered_source_bytes(&img.file_path) {
|
||||||
Some(bytes) => push_entry(
|
Some(bytes) => push_entry(
|
||||||
&mut entries,
|
&mut deps,
|
||||||
transmit_file_name(&img.file_path),
|
transmit_file_name(&img.file_path),
|
||||||
(*bytes).clone(),
|
(*bytes).clone(),
|
||||||
),
|
),
|
||||||
@@ -608,7 +608,7 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
match crate::scene::model::pdf_raster::registered_source_bytes(&def.file_path) {
|
match crate::scene::model::pdf_raster::registered_source_bytes(&def.file_path) {
|
||||||
Some(bytes) => push_entry(
|
Some(bytes) => push_entry(
|
||||||
&mut entries,
|
&mut deps,
|
||||||
transmit_file_name(&def.file_path),
|
transmit_file_name(&def.file_path),
|
||||||
(*bytes).clone(),
|
(*bytes).clone(),
|
||||||
),
|
),
|
||||||
@@ -643,12 +643,61 @@ impl OpenCADStudio {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match crate::io::plot_style::session_table(&name) {
|
match crate::io::plot_style::session_table(&name) {
|
||||||
Some(bytes) => push_entry(&mut entries, name, (*bytes).clone()),
|
Some(bytes) => push_entry(&mut deps, name, (*bytes).clone()),
|
||||||
None => push_skipped(&mut skipped, &name),
|
None => push_skipped(&mut skipped, &name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The drawing itself: the same worker-routed serialization a web
|
||||||
|
// Save performs (native copies the last-saved file; the web build
|
||||||
|
// has none, so the current document is re-serialized). The ZIP
|
||||||
|
// assembly and download are cheap and stay on the main thread —
|
||||||
|
// they continue in the completion message.
|
||||||
let zip_name = format!("{stem}_transmit.zip");
|
let zip_name = format!("{stem}_transmit.zip");
|
||||||
|
let prepared = crate::io::prepare_document_for_save(&self.tabs[i].scene.document, version);
|
||||||
|
let cmd = cmd.to_string();
|
||||||
|
Task::perform(
|
||||||
|
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
|
||||||
|
move |result| Message::WebArchiveSerialized {
|
||||||
|
cmd,
|
||||||
|
zip_name,
|
||||||
|
filename,
|
||||||
|
deps,
|
||||||
|
skipped,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web ARCHIVE completion: the serializer worker (or the inline
|
||||||
|
/// fallback) produced the drawing bytes. Assemble the transmittal ZIP —
|
||||||
|
/// the drawing entry first, then the dependencies under the same
|
||||||
|
/// name-collision rule the synchronous pass used — download it and
|
||||||
|
/// report, including the skipped-dependency lines.
|
||||||
|
pub(in crate::app) fn on_web_archive_serialized(
|
||||||
|
&mut self,
|
||||||
|
cmd: String,
|
||||||
|
zip_name: String,
|
||||||
|
filename: String,
|
||||||
|
deps: Vec<crate::io::zip::ZipEntry>,
|
||||||
|
skipped: Vec<String>,
|
||||||
|
result: Result<Vec<u8>, String>,
|
||||||
|
) -> Task<Message> {
|
||||||
|
use crate::io::zip::ZipEntry;
|
||||||
|
|
||||||
|
let drawing = match result {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => {
|
||||||
|
self.command_line.push_error(
|
||||||
|
crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(),
|
||||||
|
);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut entries: Vec<ZipEntry> = vec![ZipEntry::new(filename, drawing)];
|
||||||
|
for dep in deps {
|
||||||
|
push_entry(&mut entries, dep.name, dep.data);
|
||||||
|
}
|
||||||
match crate::io::zip::build_zip(&entries) {
|
match crate::io::zip::build_zip(&entries) {
|
||||||
Ok(zip) => {
|
Ok(zip) => {
|
||||||
crate::sys::download_bytes(&zip_name, &zip);
|
crate::sys::download_bytes(&zip_name, &zip);
|
||||||
@@ -670,6 +719,7 @@ impl OpenCADStudio {
|
|||||||
.as_ref(),
|
.as_ref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Task::none()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1367,6 +1367,25 @@ pub(super) struct PendingExternalChange {
|
|||||||
set_current_path: bool,
|
set_current_path: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One tab's slot in a web SAVEALL round (`saveall_web`): the tab identity,
|
||||||
|
/// download name and race-guard markers snapshot when serialization started,
|
||||||
|
/// plus the writer result — the future fills `result` in before the
|
||||||
|
/// completion message is built. The web counterpart of native's
|
||||||
|
/// `SaveOutcome`, minus the path/continuation bookkeeping SAVEALL never had.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub struct WebSaveallEntry {
|
||||||
|
tab_id: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
filename: String,
|
||||||
|
ext: String,
|
||||||
|
version: acadrust::DxfVersion,
|
||||||
|
geometry_epoch: u64,
|
||||||
|
edit_revision: u64,
|
||||||
|
camera_generation: u64,
|
||||||
|
result: Result<Vec<u8>, String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub struct SaveOutcome {
|
pub struct SaveOutcome {
|
||||||
@@ -2323,6 +2342,38 @@ pub enum Message {
|
|||||||
camera_generation: u64,
|
camera_generation: u64,
|
||||||
result: Result<Vec<u8>, String>,
|
result: Result<Vec<u8>, String>,
|
||||||
},
|
},
|
||||||
|
/// Web WBLOCK: the serializer worker (or the inline fallback) produced
|
||||||
|
/// the block document bytes; download them under `filename`, then run
|
||||||
|
/// the same command-line bookkeeping as `WblockWriteFinished`.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
WebWblockSerialized {
|
||||||
|
block_name: String,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
filename: String,
|
||||||
|
result: Result<Vec<u8>, String>,
|
||||||
|
},
|
||||||
|
/// Web SAVEALL round completion: one entry per tab serialized on the
|
||||||
|
/// worker (or the inline fallback). Downloads fire per entry; dirty
|
||||||
|
/// flags clear only for tabs whose serialized snapshot is still current
|
||||||
|
/// (the same race guard a single web Save uses).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
WebSaveallSerialized {
|
||||||
|
skipped: usize,
|
||||||
|
results: Vec<WebSaveallEntry>,
|
||||||
|
},
|
||||||
|
/// Web ARCHIVE/ETRANSMIT completion: the serializer worker (or the
|
||||||
|
/// inline fallback) produced the drawing bytes; assemble the transmittal
|
||||||
|
/// ZIP from the dependencies collected at command time, download it and
|
||||||
|
/// report — including the skipped-dependency lines.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
WebArchiveSerialized {
|
||||||
|
cmd: String,
|
||||||
|
zip_name: String,
|
||||||
|
filename: String,
|
||||||
|
deps: Vec<crate::io::zip::ZipEntry>,
|
||||||
|
skipped: Vec<String>,
|
||||||
|
result: Result<Vec<u8>, String>,
|
||||||
|
},
|
||||||
/// Retry the failed save after the other application releases the file.
|
/// Retry the failed save after the other application releases the file.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
SaveFileInUseRetry,
|
SaveFileInUseRetry,
|
||||||
|
|||||||
+172
-51
@@ -1708,53 +1708,97 @@ impl OpenCADStudio {
|
|||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let document = self.tabs[i].scene.document.clone();
|
let document = self.tabs[i].scene.document.clone();
|
||||||
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
||||||
let worker_name = block_name.clone();
|
// Native: extraction and the file write both run on a background
|
||||||
let worker_path = path.clone();
|
// thread, unchanged.
|
||||||
background_task(
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
move || {
|
{
|
||||||
let document = if worker_name == "*" {
|
let worker_name = block_name.clone();
|
||||||
crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles)
|
let worker_path = path.clone();
|
||||||
} else {
|
background_task(
|
||||||
crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name)
|
move || {
|
||||||
}
|
let document =
|
||||||
.map_err(|e| e.to_string())?;
|
if worker_name == "*" {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
crate::modules::insert::wblock::extract_entities_to_doc(
|
||||||
{
|
&document, &handles,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
crate::modules::insert::wblock::extract_block_to_doc(
|
||||||
|
&document, &worker_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
|
crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
|
||||||
|
},
|
||||||
|
move |result| Message::WblockWriteFinished(block_name, path, result),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Web: extraction is pure document manipulation (it reads the clone
|
||||||
|
// above and builds a fresh document) — run it inline on the main
|
||||||
|
// thread as the prepare step, together with the shared save
|
||||||
|
// preparation, then hand the DWG/DXF writer pass to the serializer
|
||||||
|
// worker the way the web Save flow does
|
||||||
|
// (`serialize_prepared_document` falls back inline when the worker
|
||||||
|
// cannot help). Completion (`WebWblockSerialized`) delivers the
|
||||||
|
// browser download plus the same `WblockWriteFinished` command-line
|
||||||
|
// bookkeeping.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let extracted = if block_name == "*" {
|
||||||
|
crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles)
|
||||||
|
} else {
|
||||||
|
crate::modules::insert::wblock::extract_block_to_doc(&document, &block_name)
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
let document = match extracted {
|
||||||
|
Ok(document) => document,
|
||||||
|
// Same reporting the background path used for a failed
|
||||||
|
// extraction — straight through the existing message.
|
||||||
|
Err(error) => {
|
||||||
|
return Task::done(Message::WblockWriteFinished(
|
||||||
|
block_name,
|
||||||
|
path,
|
||||||
|
Err(error),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
// Web: no filesystem behind the name-only path — serialize to
|
};
|
||||||
// bytes (the same document version `save` would pick, format
|
let ext = path
|
||||||
// from the chosen file name) and hand the browser a download,
|
.extension()
|
||||||
// the way the other web exports do.
|
.map(|ext| ext.to_string_lossy().into_owned())
|
||||||
#[cfg(target_arch = "wasm32")]
|
.unwrap_or_else(|| "dwg".to_string());
|
||||||
{
|
let filename = path
|
||||||
let ext = worker_path
|
.file_name()
|
||||||
.extension()
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
.map(|ext| ext.to_string_lossy().into_owned())
|
.unwrap_or_else(|| "block.dwg".into());
|
||||||
.unwrap_or_else(|| "dwg".to_string());
|
let version = document.version;
|
||||||
let bytes = crate::io::save_to_bytes(&document, &ext, document.version)?;
|
let prepared = crate::io::prepare_document_for_save(&document, version);
|
||||||
let name = worker_path
|
Task::perform(
|
||||||
.file_name()
|
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
move |result| Message::WebWblockSerialized {
|
||||||
.unwrap_or_else(|| "block.dwg".into());
|
block_name,
|
||||||
crate::sys::download_bytes(&name, &bytes);
|
path,
|
||||||
Ok(())
|
filename,
|
||||||
}
|
result,
|
||||||
},
|
},
|
||||||
move |result| Message::WblockWriteFinished(block_name, path, result),
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web SAVEALL: every dirty tab that already has a name-only path is
|
/// Web SAVEALL: every dirty tab that already has a name-only path is
|
||||||
/// serialized and downloaded in one pass — the same
|
/// serialized and downloaded in one pass — the worker-routed counterpart
|
||||||
/// `save_to_bytes` + `download_bytes` route a single Save takes. The
|
/// of a single web Save. Each tab is prepared (state sync + clone +
|
||||||
/// browser may ask once for permission to download several files, which
|
/// style/dimension bake) once up front on the main thread, then the
|
||||||
/// is the platform's price. Clean tabs are skipped (re-downloading
|
/// DWG/DXF writer passes run serially on the serializer worker — the
|
||||||
/// identical bytes is noise); tabs with no path still need SAVEAS.
|
/// same single-future structure the autosave round uses. The browser may
|
||||||
|
/// ask once for permission to download several files, which is the
|
||||||
|
/// platform's price. Clean tabs are skipped (re-downloading identical
|
||||||
|
/// bytes is noise); tabs with no path still need SAVEAS. Completion
|
||||||
|
/// (`on_web_saveall_serialized`) fires the downloads and clears each
|
||||||
|
/// tab's dirty flag — but only if the tab did not change while its
|
||||||
|
/// writer ran.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(in crate::app) fn saveall_web(&mut self) {
|
pub(in crate::app) fn saveall_web(&mut self) -> Task<Message> {
|
||||||
let mut saved = 0usize;
|
|
||||||
let mut skipped = 0usize;
|
let mut skipped = 0usize;
|
||||||
|
let mut pending: Vec<(crate::app::WebSaveallEntry, acadrust::CadDocument)> = Vec::new();
|
||||||
for t in 0..self.tabs.len() {
|
for t in 0..self.tabs.len() {
|
||||||
if self.tabs[t].is_start {
|
if self.tabs[t].is_start {
|
||||||
continue;
|
continue;
|
||||||
@@ -1781,19 +1825,91 @@ impl OpenCADStudio {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||||
match crate::io::save_to_bytes(&self.tabs[t].scene.document, &ext, version) {
|
// Race guard (the same snapshot a single web Save takes): the
|
||||||
Ok(bytes) => {
|
// markers are read after the state syncs, so completion only
|
||||||
crate::sys::download_bytes(&filename, &bytes);
|
// clears the dirty flag if the tab did not change while the
|
||||||
self.tabs[t].scene.document.version = version;
|
// writer ran off-thread.
|
||||||
self.tabs[t].dirty = false;
|
let tab = &self.tabs[t];
|
||||||
self.tabs[t].recovery_save_as_required = false;
|
let entry = crate::app::WebSaveallEntry {
|
||||||
saved += 1;
|
tab_id: tab.id,
|
||||||
|
path,
|
||||||
|
filename,
|
||||||
|
ext,
|
||||||
|
version,
|
||||||
|
geometry_epoch: tab.scene.geometry_epoch,
|
||||||
|
edit_revision: tab.edit_revision,
|
||||||
|
camera_generation: tab.scene.camera_generation,
|
||||||
|
// Placeholder — the serialization future below overwrites
|
||||||
|
// it before the completion message is built.
|
||||||
|
result: Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
pending.push((
|
||||||
|
entry,
|
||||||
|
crate::io::prepare_document_for_save(&tab.scene.document, version),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pending.is_empty() {
|
||||||
|
self.report_saveall_web(0, skipped);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let mut results = Vec::with_capacity(pending.len());
|
||||||
|
for (mut entry, prepared) in pending {
|
||||||
|
entry.result =
|
||||||
|
crate::io::serialize_prepared_document(&prepared, &entry.ext).await;
|
||||||
|
results.push(entry);
|
||||||
}
|
}
|
||||||
Err(error) => self
|
results
|
||||||
.command_line
|
},
|
||||||
.push_error(crate::tf!("SAVEALL: {} failed: {error}", path.display()).as_ref()),
|
move |results| Message::WebSaveallSerialized { skipped, results },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web SAVEALL completion: the serializer worker (or inline fallback)
|
||||||
|
/// produced each tab's bytes. The download is the only artifact of a
|
||||||
|
/// tab's save — fire it even if the tab was closed while serialization
|
||||||
|
/// was in flight. Bookkeeping matches the old synchronous pass (version
|
||||||
|
/// stamped, recovery flag cleared, dirty cleared under the race guard,
|
||||||
|
/// one error line per failed tab), then the shared summary line.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub(super) fn on_web_saveall_serialized(
|
||||||
|
&mut self,
|
||||||
|
skipped: usize,
|
||||||
|
results: Vec<crate::app::WebSaveallEntry>,
|
||||||
|
) -> Task<Message> {
|
||||||
|
let mut saved = 0usize;
|
||||||
|
for entry in results {
|
||||||
|
match entry.result {
|
||||||
|
Ok(bytes) => {
|
||||||
|
crate::sys::download_bytes(&entry.filename, &bytes);
|
||||||
|
saved += 1;
|
||||||
|
let Some(i) = self.tabs.iter().position(|tab| tab.id == entry.tab_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.tabs[i].scene.document.version = entry.version;
|
||||||
|
self.tabs[i].recovery_save_as_required = false;
|
||||||
|
let snapshot_is_current = self.tabs[i].scene.geometry_epoch
|
||||||
|
== entry.geometry_epoch
|
||||||
|
&& self.tabs[i].edit_revision == entry.edit_revision
|
||||||
|
&& self.tabs[i].scene.camera_generation == entry.camera_generation;
|
||||||
|
if snapshot_is_current {
|
||||||
|
self.tabs[i].dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => self.command_line.push_error(
|
||||||
|
crate::tf!("SAVEALL: {} failed: {error}", entry.path.display()).as_ref(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.report_saveall_web(saved, skipped);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SAVEALL summary line — shared by the nothing-queued early return
|
||||||
|
/// and the worker completion.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn report_saveall_web(&mut self, saved: usize, skipped: usize) {
|
||||||
self.command_line.push_output(
|
self.command_line.push_output(
|
||||||
crate::tf!(
|
crate::tf!(
|
||||||
"SAVEALL: saved {saved} drawing(s){}.",
|
"SAVEALL: saved {saved} drawing(s){}.",
|
||||||
@@ -2152,7 +2268,12 @@ impl OpenCADStudio {
|
|||||||
fn finish_web_save(&mut self, saved: bool) -> Task<Message> {
|
fn finish_web_save(&mut self, saved: bool) -> Task<Message> {
|
||||||
if self.save_dialog_for_unsaved {
|
if self.save_dialog_for_unsaved {
|
||||||
if saved {
|
if saved {
|
||||||
if let Some(crate::app::PendingClose::Tab(id)) = self.pending_close.take() {
|
// Only a Tab pending is consumed here; a Quit pending must
|
||||||
|
// survive to continue the quit after this save.
|
||||||
|
if let Some(crate::app::PendingClose::Tab(id)) =
|
||||||
|
self.pending_close
|
||||||
|
.take_if(|pending| matches!(pending, crate::app::PendingClose::Tab(_)))
|
||||||
|
{
|
||||||
// Resolve the id NOW: tabs may have closed while the
|
// Resolve the id NOW: tabs may have closed while the
|
||||||
// serialization ran, shifting every stored index.
|
// serialization ran, shifting every stored index.
|
||||||
if let Some(index) = self.tabs.iter().position(|tab| tab.id == id) {
|
if let Some(index) = self.tabs.iter().position(|tab| tab.id == id) {
|
||||||
|
|||||||
@@ -1239,6 +1239,27 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web WBLOCK completion: the download is the only artifact of
|
||||||
|
// the save — fire it even if the source tab was closed while
|
||||||
|
// serialization ran — then reuse the WblockWriteFinished
|
||||||
|
// bookkeeping for the command-line report.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Message::WebWblockSerialized {
|
||||||
|
block_name,
|
||||||
|
path,
|
||||||
|
filename,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
if let Ok(bytes) = &result {
|
||||||
|
crate::sys::download_bytes(&filename, bytes);
|
||||||
|
}
|
||||||
|
self.update(Message::WblockWriteFinished(
|
||||||
|
block_name,
|
||||||
|
path,
|
||||||
|
result.map(|_| ()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
Message::TableInsertStyle(value) => self.on_table_insert_style(value),
|
Message::TableInsertStyle(value) => self.on_table_insert_style(value),
|
||||||
Message::TableInsertField(field) => self.on_table_insert_field(field),
|
Message::TableInsertField(field) => self.on_table_insert_field(field),
|
||||||
Message::TableInsertApply => self.on_table_insert_apply(),
|
Message::TableInsertApply => self.on_table_insert_apply(),
|
||||||
@@ -8259,6 +8280,21 @@ impl OpenCADStudio {
|
|||||||
result,
|
result,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Message::WebSaveallSerialized { skipped, results } => {
|
||||||
|
self.on_web_saveall_serialized(skipped, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Message::WebArchiveSerialized {
|
||||||
|
cmd,
|
||||||
|
zip_name,
|
||||||
|
filename,
|
||||||
|
deps,
|
||||||
|
skipped,
|
||||||
|
result,
|
||||||
|
} => self.on_web_archive_serialized(cmd, zip_name, filename, deps, skipped, result),
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),
|
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ fn parsed_cache() -> &'static Mutex<HashMap<String, acadrust::ReadOutcome>> {
|
|||||||
/// drawing's xref references will be matched against. Registering supersedes
|
/// drawing's xref references will be matched against. Registering supersedes
|
||||||
/// any eager parse cached for the same file, so a re-pick (or a drop that
|
/// any eager parse cached for the same file, so a re-pick (or a drop that
|
||||||
/// finally supplies a missing reference) reloads from the new bytes.
|
/// finally supplies a missing reference) reloads from the new bytes.
|
||||||
|
///
|
||||||
|
/// Names are bare on the web, so two DISTINCT same-named drawings cannot be
|
||||||
|
/// told apart: the newest registration wins for resolution, ARCHIVE, and
|
||||||
|
/// XRELOAD alike — consistent with what the drawing itself displays, but a
|
||||||
|
/// fundamental browser limit (native distinguishes them by folder).
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source
|
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source
|
||||||
pub fn register_source(name: &str, bytes: Arc<Vec<u8>>) {
|
pub fn register_source(name: &str, bytes: Arc<Vec<u8>>) {
|
||||||
{
|
{
|
||||||
@@ -831,6 +836,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beyond SOURCE_CAP registrations the oldest leave first, and a
|
||||||
|
/// re-registration of a live name neither evicts nor duplicates it.
|
||||||
|
#[test]
|
||||||
|
fn source_registry_evicts_oldest_beyond_the_cap() {
|
||||||
|
for i in 0..(SOURCE_CAP + 2) {
|
||||||
|
register_source(&format!("f{i}.dwg"), Arc::new(vec![i as u8]));
|
||||||
|
}
|
||||||
|
let (map_len, order_len, has_newest, has_oldest) = {
|
||||||
|
// Scoped: the guard must drop before register_source locks again.
|
||||||
|
let cache = source_cache()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
(
|
||||||
|
cache.0.len(),
|
||||||
|
cache.1.len(),
|
||||||
|
cache.0.contains_key(&format!("f{}.dwg", SOURCE_CAP + 1)),
|
||||||
|
cache.0.contains_key("f0.dwg"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(map_len, SOURCE_CAP);
|
||||||
|
assert_eq!(order_len, SOURCE_CAP);
|
||||||
|
assert!(has_newest);
|
||||||
|
assert!(!has_oldest);
|
||||||
|
// Re-registering a live name refreshes its bytes without reordering.
|
||||||
|
register_source(&format!("f{}.dwg", SOURCE_CAP + 1), Arc::new(vec![255]));
|
||||||
|
let (order_len, refreshed) = {
|
||||||
|
let cache = source_cache()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
(
|
||||||
|
cache.1.len(),
|
||||||
|
cache
|
||||||
|
.0
|
||||||
|
.get(&format!("f{}.dwg", SOURCE_CAP + 1))
|
||||||
|
.map(|b| b.to_vec()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(order_len, SOURCE_CAP, "no duplicate order entry");
|
||||||
|
assert_eq!(refreshed, Some(vec![255]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_source_supersedes_a_cached_parse_of_the_same_file() {
|
fn register_source_supersedes_a_cached_parse_of_the_same_file() {
|
||||||
let name = "Registry-Supersede-Plan.dwg";
|
let name = "Registry-Supersede-Plan.dwg";
|
||||||
|
|||||||
+3
-3
@@ -149,11 +149,11 @@ pub fn build_zip(entries: &[ZipEntry]) -> Result<Vec<u8>, String> {
|
|||||||
|
|
||||||
let central_offset = u32::try_from(out.len())
|
let central_offset = u32::try_from(out.len())
|
||||||
.map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?;
|
.map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?;
|
||||||
if central_offset == u32::MAX {
|
|
||||||
return Err("archive exceeds the classic-ZIP limit".to_string());
|
|
||||||
}
|
|
||||||
let central_size = u32::try_from(central.len())
|
let central_size = u32::try_from(central.len())
|
||||||
.map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?;
|
.map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?;
|
||||||
|
if central_offset == u32::MAX || central_size == u32::MAX {
|
||||||
|
return Err("archive exceeds the classic-ZIP limit".to_string());
|
||||||
|
}
|
||||||
out.extend_from_slice(¢ral);
|
out.extend_from_slice(¢ral);
|
||||||
|
|
||||||
// End of central directory record — no archive comment.
|
// End of central directory record — no archive comment.
|
||||||
|
|||||||
Reference in new issue
Block a user