web: autosave snapshots with crash-recovery restore, persistent storage

The web build had no autosave: on_autosave was a no-op and the
subscription was native-only. Both gates are gone; every savetime tick
now serializes each dirty tab (same pre-serialize sync as the web save
path, no thumbnail, serialized serially off the critical paint path)
into a dedicated opencadstudio-autosave OPFS directory, keyed by
drawing name, without touching the dirty flag, downloads, or recents.

On boot the web app scans that directory and offers any snapshot
newer than the last saved copy in a localized Recover Drawing modal
(Restore opens it as "name (recovered).dwg" through the standard
open pipeline; Discard removes it; Escape discards). Saving or
closing a drawing clears its snapshot; the boot scan also garbage-
collects stale entries. run_web now requests navigator.storage.persist
so settings and OPFS data survive browser eviction.
This commit is contained in:
Afonsop committed 2026-09-16 09:36:46 +01:00
1 parent ad60be11ed
commit 101d167ebc
31 files changed
+746 -11

No files matched your search

+65 -1
View File
@@ -724,6 +724,18 @@ pub(super) struct OpenCADStudio {
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: Option<PendingWebThumbnailSave>,
/// One web autosave round is running; ticks that fire while it lasts are
/// dropped instead of queued (the next interval retries).
#[cfg(target_arch = "wasm32")]
web_autosave_in_flight: bool,
/// Web autosave storage already reported a failure — stay quiet until a
/// round succeeds again, so a full quota does not spam every tick.
#[cfg(target_arch = "wasm32")]
web_autosave_warned: bool,
/// Crash-recovery snapshots found at boot (newest first) that the restore
/// prompt still has to offer, one at a time.
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
// ── Floating panel windows ────────────────────────────────────────────
/// Active `iced_aw` colour picker: destination plus its initial colour.
color_pick_target: Option<(ColorPickTarget, AcadColor)>,
@@ -1770,6 +1782,11 @@ pub enum ModalKind {
/// means and what usually fixes it. Queued once per verdict; the status
/// bar's ⚠ pill reopens it.
GpuWarning,
/// Web: a crash-recovery autosave snapshot newer than the drawing's last
/// save awaits a restore/discard decision. Distinct from the corrupt-file
/// `RecoveryPrompt` — nothing is wrong with the drawing itself.
#[cfg(target_arch = "wasm32")]
AutosaveRestore,
}
/// A property group controlled by a layer state's restore mask.
@@ -1994,6 +2011,23 @@ pub enum Message {
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
#[cfg(target_arch = "wasm32")]
WebRecentStored(Result<PathBuf, String>),
/// Web: the boot scan finished — these autosave snapshots are newer than
/// the drawings' last saved copies (newest first).
#[cfg(target_arch = "wasm32")]
WebAutosavesScanned(Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>),
/// Web: restore the offered autosave snapshot into a recovered tab.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestore,
/// Web: delete the offered autosave snapshot (and offer the next, if any).
#[cfg(target_arch = "wasm32")]
WebAutosaveDiscard,
/// Web: the offered snapshot's bytes are in (and the OPFS copy consumed);
/// open them as `name`.
#[cfg(target_arch = "wasm32")]
WebAutosaveBytes(String, Result<Vec<u8>, String>),
/// Web: one autosave round finished for all dirty tabs (serially).
#[cfg(target_arch = "wasm32")]
WebAutosaveSnapshotFinished(Result<(), String>),
SaveFile,
SaveAs,
// ── Custom Save-As dialog ─────────────────────────────────────────────
@@ -3729,6 +3763,12 @@ impl OpenCADStudio {
pending_native_thumbnail_save: None,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: None,
#[cfg(target_arch = "wasm32")]
web_autosave_in_flight: false,
#[cfg(target_arch = "wasm32")]
web_autosave_warned: false,
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec::new(),
color_pick_target: None,
color_picker_tab: ColorPickerTab::Index,
recent_colors: Vec::new(),
@@ -4258,9 +4298,23 @@ impl OpenCADStudio {
Message::DiscussionsFetched,
);
let thumbs_fetch = s.refresh_recent_thumbs();
// Crash-recovery scan: OPFS snapshots newer than a drawing's last
// saved copy come back as a restore prompt (stale ones are deleted).
let autosave_scan = Task::perform(
crate::io::web_autosave::scan(),
Message::WebAutosavesScanned,
);
(
s,
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch]),
Task::batch([
focus,
fonts,
patrons,
videos,
discussions,
thumbs_fetch,
autosave_scan,
]),
)
}
}
@@ -4320,6 +4374,16 @@ pub fn run_web() -> iced::Result {
// winit never emits DroppedFile on the web, so the document drag & drop
// listeners are installed here and feed the control poll (#344).
crate::io::web_drop::install();
// Best-effort, fire-and-forget: ask the browser not to evict the origin's
// storage under pressure — settings (localStorage) and the OPFS recent
// copies / autosave snapshots live there. A denial changes nothing.
wasm_bindgen_futures::spawn_local(async {
if let Some(window) = web_sys::window() {
if let Ok(promise) = window.navigator().storage().persist() {
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
}
});
iced::application(
OpenCADStudio::boot_web,
OpenCADStudio::update,
+14
View File
@@ -24,6 +24,20 @@ impl OpenCADStudio {
}
}
/// Queue the web crash-recovery prompt behind whatever startup modal is
/// already showing (donation, GPU warning…); the after-message hook in
/// `update` surfaces it as soon as the slot is free.
#[cfg(target_arch = "wasm32")]
pub(super) fn queue_autosave_restore_prompt(&mut self) {
if !self
.pending_startup_modals
.contains(&ModalKind::AutosaveRestore)
{
self.pending_startup_modals.push_back(ModalKind::AutosaveRestore);
}
self.show_next_startup_modal();
}
/// Notice when the scene is drawn by a software rasterizer, or not at
/// all, and say so everywhere the user might look: the command line,
/// the log, a one-time popup, and a status-bar pill that stays.
+215
View File
@@ -1906,6 +1906,14 @@ impl OpenCADStudio {
}
let mut recent_task = Task::none();
// The name the autosave snapshot for this tab is keyed by — capture it
// before the save re-points `current_path` at the download name.
let autosave_key = self.tabs[i]
.current_path
.as_ref()
.and_then(|path| path.file_name())
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| self.tabs[i].tab_display_name());
let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
@@ -1926,6 +1934,24 @@ impl OpenCADStudio {
},
Message::WebRecentStored,
);
// A clean save supersedes any crash-recovery snapshot for this
// drawing — drop the old key and the new one so stale copies
// don't resurface in the next boot scan.
let new_key = self.tabs[i]
.current_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
recent_task = Task::batch([
recent_task,
Task::perform(
async move {
let _ = crate::io::web_autosave::remove(&autosave_key).await;
let _ = crate::io::web_autosave::remove(&new_key).await;
},
|_| Message::Noop,
),
]);
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
true
@@ -2871,11 +2897,185 @@ impl OpenCADStudio {
Task::batch(tasks)
}
/// Periodic autosave on the web (SAVETIME): serialize every dirty tab to
/// an OPFS snapshot (`io::web_autosave`), the browser counterpart of the
/// native `.sv$` copy — best-effort and non-destructive: no download, no
/// recents update, dirty flag untouched. Snapshots run serially in one
/// round; while it lasts (or a web save's thumbnail capture is pending)
/// further ticks are dropped, not queued.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() {
return Task::none();
}
let mut pending = Vec::new();
for i in 0..self.tabs.len() {
if !self.tabs[i].dirty {
continue;
}
// Same pre-serialize state sync the web Save flow performs
// (on_save_dialog_confirm): stamp the live camera, vport and
// sysvars into the document so the snapshot round-trips.
self.sync_view_state_for_save(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.sync_solid_models_for_save(i);
self.tabs[i].scene.sync_native_parametric_graph();
let tab = &self.tabs[i];
// Key by the drawing's display name (the recent copy's name), so
// the boot scan can compare the two; untitled tabs use the tab
// title, which is what Save would download as.
let name = tab
.current_path
.as_ref()
.and_then(|path| path.file_name())
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| tab.tab_display_name());
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document);
let ext = if is_dxf { "dxf" } else { "dwg" }.to_string();
let version = tab.scene.document.version;
pending.push((name, ext, version, tab.scene.document.clone()));
}
if pending.is_empty() {
return Task::none();
}
self.web_autosave_in_flight = true;
Task::perform(
async move {
let mut failure = None;
for (name, ext, version, doc) in pending {
// Autosave exists to recover geometry, not pretty recents:
// no thumbnail capture, just bytes into OPFS.
match crate::io::save_to_bytes(&doc, &ext, version) {
Ok(bytes) => {
if let Err(error) =
crate::io::web_autosave::snapshot(&name, &bytes, None).await
{
failure = Some(error);
}
}
Err(error) => failure = Some(error),
}
}
failure.map_or(Ok(()), Err)
},
Message::WebAutosaveSnapshotFinished,
)
}
/// One web autosave round finished. Silent on success; on failure one
/// command-line warning until a round succeeds again.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task<Message> {
self.web_autosave_in_flight = false;
match result {
Ok(()) => self.web_autosave_warned = false,
Err(error) => {
if !self.web_autosave_warned {
self.web_autosave_warned = true;
self.command_line.push_warning(
crate::tf!("Autosave copy could not be stored: {error}").as_ref(),
);
}
}
}
Task::none()
}
/// The boot scan decided which snapshots are recoverable; queue the
/// restore prompt (behind any already-queued startup modal).
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosaves_scanned(
&mut self,
result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
) -> Task<Message> {
let Ok(mut entries) = result else {
// Best-effort feature: no storage access, no prompt.
return Task::none();
};
entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
self.web_autosave_pending = entries;
self.queue_autosave_restore_prompt();
Task::none()
}
/// Restore the offered snapshot: read (and consume) the OPFS copy, then
/// open it as a recovered tab through the standard web-bytes open flow.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_restore(&mut self) -> Task<Message> {
let Some(entry) = self.web_autosave_pending.first().cloned() else {
self.close_active_modal();
return Task::none();
};
self.web_autosave_pending.remove(0);
self.close_active_modal();
let open_name = crate::io::web_autosave::recovered_name(&entry.name);
let key = entry.name;
Task::perform(
async move {
let bytes = crate::io::web_autosave::read(&key).await?;
// The snapshot is spent: the recovered tab (and its recent
// copy) replaces it from here on.
let _ = crate::io::web_autosave::remove(&key).await;
Ok(bytes)
},
move |result| Message::WebAutosaveBytes(open_name, result),
)
}
/// Discard the offered snapshot; the prompt stays up for the next one.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_discard(&mut self) -> Task<Message> {
if let Some(entry) = self.web_autosave_pending.first().cloned() {
self.web_autosave_pending.remove(0);
wasm_bindgen_futures::spawn_local(async move {
let _ = crate::io::web_autosave::remove(&entry.name).await;
});
}
if self.web_autosave_pending.is_empty() {
self.close_active_modal();
}
Task::none()
}
/// The offered snapshot's bytes arrived (or did not): open the recovered
/// tab, or warn and move on to the next snapshot.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_bytes(
&mut self,
open_name: String,
result: Result<Vec<u8>, String>,
) -> Task<Message> {
match result {
Ok(bytes) => {
self.command_line.push_output(
crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref(),
);
let task = self.update(Message::WebFileDropped(open_name, bytes));
// Further snapshots wait behind this one; queue the prompt
// again now that an open is in flight, so it reappears when
// the recovered drawing finishes opening.
if !self.web_autosave_pending.is_empty()
&& !self
.pending_startup_modals
.contains(&crate::app::ModalKind::AutosaveRestore)
{
self.pending_startup_modals
.push_back(crate::app::ModalKind::AutosaveRestore);
}
task
}
Err(error) => {
self.command_line
.push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref());
if !self.web_autosave_pending.is_empty() {
self.queue_autosave_restore_prompt();
}
Task::none()
}
}
}
/// Delete the `.sv$` autosave recovery files for all open drawings. They
/// exist only to survive a crash, so a clean save or exit removes them.
pub(in crate::app) fn cleanup_autosaves(&self) {
@@ -2883,6 +3083,21 @@ impl OpenCADStudio {
for i in 0..self.tabs.len() {
let _ = std::fs::remove_file(self.autosave_target(i));
}
// Web counterpart: drop each open tab's OPFS snapshot. A tab close is
// not an exit, so this only runs on the explicit exit path; anything
// the teardown race leaves behind is caught by the next boot scan.
#[cfg(target_arch = "wasm32")]
for tab in &self.tabs {
let name = tab
.current_path
.as_ref()
.and_then(|path| path.file_name())
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| tab.tab_display_name());
wasm_bindgen_futures::spawn_local(async move {
let _ = crate::io::web_autosave::remove(&name).await;
});
}
}
/// Remove the autosave recovery files, then quit the application.
+17
View File
@@ -959,6 +959,17 @@ impl OpenCADStudio {
}
},
#[cfg(target_arch = "wasm32")]
Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveRestore => self.on_web_autosave_restore(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveBytes(name, result) => self.on_web_autosave_bytes(name, result),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveSnapshotFinished(result) => self.on_web_autosave_finished(result),
Message::ImagePick => {
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
}
@@ -7054,6 +7065,12 @@ impl OpenCADStudio {
if self.active_modal == Some(super::ModalKind::RecoveryPrompt) {
return self.update(Message::RecoveryDecline);
}
// Closing the crash-recovery prompt (✕ or Escape) discards
// the offered snapshot — otherwise it would requeue itself.
#[cfg(target_arch = "wasm32")]
if self.active_modal == Some(super::ModalKind::AutosaveRestore) {
return self.update(Message::WebAutosaveDiscard);
}
// Closing the shortcut editor with un-applied rows needs an
// explicit discard; a second close attempt (or the overlay's
// Discard button) goes through.
+3 -5
View File
@@ -2476,9 +2476,9 @@ impl OpenCADStudio {
iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts);
#[cfg(not(target_arch = "wasm32"))]
let web_fonts = Subscription::none();
// Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is
// the interval in minutes; 0 disables it.
#[cfg(not(target_arch = "wasm32"))]
// Periodic autosave (SAVETIME): a `.sv$` recovery copy beside the file
// on native, an OPFS snapshot on web. SAVETIME is the interval in
// minutes; 0 disables it.
let autosave = if self.savetime_min > 0 {
iced::time::every(std::time::Duration::from_secs(
self.savetime_min as u64 * 60,
@@ -2487,8 +2487,6 @@ impl OpenCADStudio {
} else {
Subscription::none()
};
#[cfg(target_arch = "wasm32")]
let autosave = Subscription::none();
// Drawings handed over by a second launch (single instance). Inert in a
// process that lost the port election, so it costs nothing there.
#[cfg(not(target_arch = "wasm32"))]
+47
View File
@@ -54,6 +54,8 @@ impl OpenCADStudio {
Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"),
Some(K::Recovery) => crate::tr!("modal", "recovery-report"),
Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"),
#[cfg(target_arch = "wasm32")]
Some(K::AutosaveRestore) => crate::t!("Recover Drawing").into_owned(),
Some(K::GpuWarning) => crate::tr!("gpu", "title"),
None => String::new(),
}
@@ -1589,6 +1591,17 @@ impl OpenCADStudio {
)
})
}
#[cfg(target_arch = "wasm32")]
super::super::ModalKind::AutosaveRestore => {
let name = self
.web_autosave_pending
.first()
.map(|entry| entry.name.clone())
.unwrap_or_default();
automatic_flow(ex, |flow| {
autosave_restore_dialog_window(&name, flow)
})
}
})
}
}
@@ -1761,6 +1774,40 @@ fn unsaved_changes_dialog_window(
.into()
}
/// Web crash-recovery prompt: an autosave snapshot of `name` is newer than
/// the drawing's last save. Restore opens it as a recovered tab; Discard
/// deletes the snapshot. Mirrors the unsaved-changes dialog's layout.
#[cfg(target_arch = "wasm32")]
fn autosave_restore_dialog_window(
name: &str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'static, Message> {
container(
column![
text(crate::tf!(
"\"{name}\" has an autosave copy newer than the last save."
))
.size(14),
iced::widget::Space::new().height(4),
text(t!("Restore it as a recovered tab, or discard the copy."))
.size(12)
.style(dialog_muted_text_style),
iced::widget::Space::new().height(20),
row![
dialog_button(t!("Restore"), Message::WebAutosaveRestore, button::primary),
iced::widget::Space::new().width(8),
dialog_button(t!("Discard"), Message::WebAutosaveDiscard, button::danger),
],
]
.spacing(0),
)
.style(dialog_body_style)
.center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28])
.into()
}
#[cfg(not(target_arch = "wasm32"))]
fn file_in_use_dialog_window(
path: &str,