feat: restore-all session recovery, ?file= deep links, offline PWA

Session recovery: more than one pending snapshot adds a 'Restore all'
button to the Recover Drawing modal — every snapshot goes through the
exact same guarded path as a single restore (deleted only after its
own open succeeds). The open-tab list and active tab persist to
localStorage on web (open/save/close) and the remembered tab is
re-selected once a restore batch settles; the modal notes how many
drawings the session had. Nothing is auto-opened.

Deep links: ?file=<url|name> opens a drawing at boot. http(s) URLs
must end in .dwg/.dxf and are fetched with the same streaming,
64 MB-capped, 8 s-deadline reader the remote images use, then go
through the guarded open queue; cross-origin opens announce the
origin first. Bare names resolve from the browser's recent storage,
with a hint when absent. Everything else (file:, javascript:, drive
letters, non-drawing tails) is refused with a console note.

Offline PWA: the app was installable (manifest) but downloaded its
55 MB bundle on every visit. A cache-first service worker now serves
the whole bundle from Cache Storage after one online visit —
navigations network-first so updates land, hashed assets
stale-while-revalidate, non-GET/cross-origin passthrough, old caches
cleaned on activate, and the waiting-worker update flow documented.
The build wrapper stamps the cache version from the bundle hash and
hard-fails if sw.js is missing or unstamped.

Also fixes a locale group mismatch on the DATALINK pick notice
(catalog said a display group that does not exist in the ftl
sources).
This commit is contained in:
Afonsop committed 2026-09-18 16:27:12 +01:00
1 parent d719fb7778
commit 5f9b345ee6
38 files changed
+1211 -50

No files matched your search

+77
View File
@@ -736,6 +736,25 @@ pub(super) struct OpenCADStudio {
/// prompt still has to offer, one at a time.
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
/// Snapshots a "Restore all" click still expects to settle (opened,
/// failed, cancelled, unreadable, or rejected by the open queue). Each
/// settle funnels through `queue_next_web_autosave_prompt`, which counts
/// this down; reaching zero arms `web_session_focus_pending`.
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: usize,
/// A Restore-all batch finished settling — apply the remembered
/// session's active tab at the next message boundary, after the last
/// restored open has actually inserted its tab (`web_autosave_opened`
/// runs before that insertion, so applying there would pick against a
/// tab list still missing the last restore).
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: bool,
/// The tab list the previous session remembered (localStorage), loaded
/// after the boot autosave scan. Advisory only: it feeds the restore
/// modal's session line and the post-restore tab selection — nothing is
/// ever auto-opened from it.
#[cfg(target_arch = "wasm32")]
web_session: Option<crate::io::web_session::WebSession>,
/// The snapshot whose recovered open is in flight: `(autosave key,
/// open id)`. The OPFS copy is the only one, so it is deleted only
/// once the open with exactly that id succeeds — identity, never the
@@ -2045,6 +2064,11 @@ 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 `?file=` boot deep link settled — its bytes entered the
/// guarded open queue, or a notice/error text for the command line
/// (recent copy missing, refused or failed fetch).
#[cfg(target_arch = "wasm32")]
WebLinkSettled(crate::io::web_link::LinkOutcome),
/// Web: the boot scan finished — these autosave snapshots are newer than
/// the drawings' last saved copies (newest first).
#[cfg(target_arch = "wasm32")]
@@ -2052,6 +2076,12 @@ pub enum Message {
/// Web: restore the offered autosave snapshot into a recovered tab.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestore,
/// Web: restore EVERY pending autosave snapshot — each is read and
/// enqueued through the guarded open queue with its own key (the poll
/// opens them sequentially); a snapshot is still consumed only after its
/// own open succeeds.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestoreAll,
/// Web: delete the offered autosave snapshot (and offer the next, if any).
#[cfg(target_arch = "wasm32")]
WebAutosaveDiscard,
@@ -3895,6 +3925,12 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec::new(),
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: 0,
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: false,
#[cfg(target_arch = "wasm32")]
web_session: None,
#[cfg(target_arch = "wasm32")]
web_autosave_consumed: None,
#[cfg(target_arch = "wasm32")]
web_autosave_clear_after: Vec::new(),
@@ -4443,6 +4479,46 @@ impl OpenCADStudio {
crate::io::web_autosave::scan(),
Message::WebAutosavesScanned,
);
// URL deep link (`?file=…`): a shareable/embeddable link that opens a
// drawing at boot. An http(s) URL is fetched with the same capped
// streaming reader the remote-image fetch uses and handed to the
// guarded drop-open queue; a bare name reads the OPFS recent copy the
// same way `open_recent_web` does. Everything else (other schemes,
// non-.dwg/.dxf URL tails) is refused with a console warning — the
// same scheme gate the image fetch applies. Pure parsing/validation
// lives in `io::web_link` and is unit-tested there.
let deep_link: Task<Message> = {
let search = web_sys::window()
.and_then(|window| window.location().search().ok())
.unwrap_or_default();
match crate::io::web_link::parse_link(&search) {
crate::io::web_link::ParsedLink::Target(target) => {
// Opening a URL from the query string is an implicit trust
// decision: name the origin before the fetch starts (info
// only — the bytes still go through the parse + recovery
// machinery like any other open). Same-origin links stay
// quiet: that drawing already comes from this site.
if let crate::io::web_link::LinkTarget::Url { url, name } = &target {
if !crate::io::web_link::same_origin(url) {
let origin = crate::io::web_link::display_origin(url)
.unwrap_or_else(|| url.clone());
s.command_line
.push_info(&format!("Opening {name} from {origin}…"));
}
}
Task::perform(crate::io::web_link::open_link(target), |outcome| {
Message::WebLinkSettled(outcome)
})
}
crate::io::web_link::ParsedLink::Ignored(refusal, value) => {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
&crate::io::web_link::refusal_warning(refusal, &value),
));
Task::none()
}
crate::io::web_link::ParsedLink::Absent => Task::none(),
}
};
(
s,
Task::batch([
@@ -4453,6 +4529,7 @@ impl OpenCADStudio {
discussions,
thumbs_fetch,
autosave_scan,
deep_link,
]),
)
}
+4
View File
@@ -141,6 +141,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if let Some(tab_id) = tab_id {
v4_support::on_tab_closed(tab_id);
}
// The tab list just shrank: refresh the remembered session
// list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
Task::none()
}
+4
View File
@@ -240,6 +240,10 @@ impl OpenCADStudio {
// last selection. #21.
self.sync_ribbon_layers();
self.sync_ribbon_from_selection();
// The tab list just shrank: refresh the remembered
// session list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
return Task::batch([close_win, self.continue_tab_close_queue()]);
}
Some(crate::app::PendingClose::Quit) => {
+116 -2
View File
@@ -1698,6 +1698,10 @@ impl OpenCADStudio {
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
// The tab list just changed (and the active tab with it): remember
// it for the next boot's restore flow (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
let pending_open_task = if let Some(report) = recovery_report {
self.recovery_report = Some(report);
self.active_modal = Some(crate::app::ModalKind::Recovery);
@@ -2231,6 +2235,9 @@ impl OpenCADStudio {
);
self.tabs[i].scene.document.version = version;
self.tabs[i].recovery_save_as_required = false;
// A save may have renamed the tab (Save As): refresh the remembered
// session list (web session memory).
self.persist_web_session();
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
&& self.tabs[i].edit_revision == edit_revision
&& self.tabs[i].scene.camera_generation == camera_generation;
@@ -3342,12 +3349,16 @@ impl OpenCADStudio {
/// restore prompt (behind any already-queued startup modal). An empty
/// scan — a fresh profile, or everything already cleaned up — must not
/// surface an empty "Recover Drawing" modal, so the prompt is only
/// queued when there is something to offer.
/// queued when there is something to offer. The remembered session list
/// is loaded right after the scan: it never opens anything itself, it
/// only names what the session had (a line in the restore modal) and
/// picks the active tab once restores complete.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosaves_scanned(
&mut self,
result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
) -> Task<Message> {
self.web_session = crate::io::web_session::load();
let Ok(mut entries) = result else {
// Best-effort feature: no storage access, no prompt.
return Task::none();
@@ -3384,6 +3395,44 @@ impl OpenCADStudio {
)
}
/// "Restore all": the whole pending list goes through the exact same
/// guarded path as a single restore — one read per snapshot, then
/// `enqueue_restore` with its own key, and the 50 ms poll opens them
/// sequentially. Nothing about consumption changes: each snapshot is
/// still deleted only when its own open succeeds (matched by open id),
/// and any failure (unreadable, parse error, cancel) keeps that
/// snapshot in OPFS for the next boot. The batch counts its settles in
/// `web_autosave_restore_all_remaining`; every settle path funnels
/// through `queue_next_web_autosave_prompt`, so the countdown — and the
/// remembered session's tab selection when it hits zero — needs no
/// extra bookkeeping sites.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_restore_all(&mut self) -> Task<Message> {
// Fewer than two pending: this is just Restore.
if self.web_autosave_pending.len() < 2 {
return self.on_web_autosave_restore();
}
let entries = std::mem::take(&mut self.web_autosave_pending);
self.web_autosave_restore_all_remaining = entries.len();
self.close_active_modal();
self.command_line.push_info(
crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(),
);
let reads = crate::io::web_autosave::restore_all_plan(&entries)
.into_iter()
.map(|(open_name, key)| {
// Same contract as the single restore: the key travels with
// the bytes so the deletion marker is recorded by open id.
let read_key = key.clone();
Task::perform(
async move { crate::io::web_autosave::read(&read_key).await },
move |result| Message::WebAutosaveBytes(open_name, key, result),
)
})
.collect::<Vec<_>>();
Task::batch(reads)
}
/// The recovered drawing opened: its autosave snapshot — held back until
/// now — can finally be deleted, and the next queued snapshot can be
/// offered. The caller already matched the marker to this open's id.
@@ -3477,14 +3526,79 @@ impl OpenCADStudio {
}
/// Re-offer the remaining boot snapshots once the current one settled.
/// No-op when none are left (or the modal is already queued).
/// No-op when none are left (or the modal is already queued). This is
/// also the single funnel every restore settle runs through (opened,
/// failed, cancelled, unreadable, or queue-rejected), so a Restore-all
/// batch counts down here — and when its last snapshot settles, the
/// remembered session decides which tab ends up active.
#[cfg(target_arch = "wasm32")]
pub(super) fn queue_next_web_autosave_prompt(&mut self) {
if self.web_autosave_restore_all_remaining > 0 {
self.web_autosave_restore_all_remaining -= 1;
if self.web_autosave_restore_all_remaining == 0 {
// Applied at the next message boundary: the last restore's
// settle (`web_autosave_opened`) runs BEFORE its own open
// inserts the tab, so focusing here could not see it yet.
self.web_session_focus_pending = true;
}
}
if !self.web_autosave_pending.is_empty() {
self.queue_autosave_restore_prompt();
}
}
/// A Restore-all batch has fully settled: focus the tab the crashed
/// session remembered as active, if it actually came back (or is open
/// under its own name). Restored tabs carry the "(recovered)" marker —
/// `recovered_name` maps the remembered name onto it idempotently. A
/// no-op without remembered memory, or when the remembered tab did not
/// return; the last-opened tab then stays active, which is fine. Called
/// from the after-message hook (`web_session_focus_pending`), so the
/// whole settle batch — including the last tab insertion — is done.
#[cfg(target_arch = "wasm32")]
pub(super) fn select_remembered_session_tab(&mut self) {
let Some(session) = self.web_session.as_ref() else {
return;
};
let Some(remembered) = session.tabs.get(session.active) else {
return;
};
let recovered = crate::io::web_autosave::recovered_name(remembered);
let Some(index) = self.tabs.iter().position(|tab| {
let name = tab.tab_display_name();
name == remembered.as_str() || name == recovered
}) else {
return;
};
self.active_tab = index;
// The active tab changed under the ribbon; re-sync its chips like
// every other tab switch does (#21).
self.sync_ribbon_layers();
self.sync_ribbon_styles();
self.sync_ribbon_from_selection();
}
/// Remember the open-tab list (names + active index) in localStorage —
/// the web session memory the next boot's restore flow consults. Cheap
/// by design: a small `Vec<String>` + index, rewritten in full. Called
/// on every save/open/close; the Start tab is not a drawing and is
/// skipped, with the active index remapped onto the filtered list.
#[cfg(target_arch = "wasm32")]
pub(super) fn persist_web_session(&self) {
let mut names: Vec<String> = Vec::new();
let mut active = 0;
for (index, tab) in self.tabs.iter().enumerate() {
if tab.is_start {
continue;
}
if index == self.active_tab {
active = names.len();
}
names.push(tab.tab_display_name());
}
crate::io::web_session::persist(&names, active);
}
/// 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(&mut self) {
+24
View File
@@ -291,6 +291,14 @@ impl OpenCADStudio {
let task = self.update_inner(msg);
self.refresh_gpu_status();
self.show_next_startup_modal();
// A Restore-all batch finished settling on this message: now that
// the last restored open has inserted its tab, focus the tab the
// remembered session had active (web session memory).
#[cfg(target_arch = "wasm32")]
if self.web_session_focus_pending {
self.web_session_focus_pending = false;
self.select_remembered_session_tab();
}
self.sync_open_command_history();
// Close the document-level first-touch transaction started by
// push_undo_snapshot at this message boundary.
@@ -1135,11 +1143,27 @@ impl OpenCADStudio {
}
},
// The `?file=` boot deep link settled. Queued bytes are drained by
// the control poll's drop queue like any drop — nothing to say
// until that open starts (or its parse error surfaces there).
#[cfg(target_arch = "wasm32")]
Message::WebLinkSettled(outcome) => {
use crate::io::web_link::LinkOutcome;
match outcome {
LinkOutcome::Queued => {}
LinkOutcome::Info(text) => self.command_line.push_info(&text),
LinkOutcome::Error(text) => self.command_line.push_error(&text),
}
Task::none()
},
#[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::WebAutosaveRestoreAll => self.on_web_autosave_restore_all(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDefer => self.on_web_autosave_defer(),
+62 -23
View File
@@ -1599,8 +1599,15 @@ impl OpenCADStudio {
.first()
.map(|entry| entry.name.clone())
.unwrap_or_default();
let pending = self.web_autosave_pending.len();
// What the crashed session had open, when it remembered.
let session_drawings = self
.web_session
.as_ref()
.map(|session| session.tabs.len())
.filter(|count| *count > 0);
automatic_flow(ex, |flow| {
autosave_restore_dialog_window(&name, flow)
autosave_restore_dialog_window(&name, pending, session_drawings, flow)
})
}
})
@@ -1777,36 +1784,68 @@ fn unsaved_changes_dialog_window(
/// 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.
/// deletes the snapshot. With more than one snapshot still pending, "Restore
/// all" puts the whole remaining list through the same guarded open queue.
/// When the crashed session left a tab list behind, a muted line says how
/// much it had open. Mirrors the unsaved-changes dialog's layout.
#[cfg(target_arch = "wasm32")]
fn autosave_restore_dialog_window(
name: &str,
pending: usize,
session_drawings: Option<usize>,
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),
let mut body = column![text(crate::tf!(
"\"{name}\" has an autosave copy newer than the last save."
))
.size(14)]
.spacing(0);
if let Some(count) = session_drawings {
body = body
.push(iced::widget::Space::new().height(4))
.push(
text(crate::tf!("Session had {count} open drawing(s).", count = count))
.size(12)
.style(dialog_muted_text_style),
);
}
body = body
.push(iced::widget::Space::new().height(4))
.push(
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()
)
.push(iced::widget::Space::new().height(20));
let mut buttons = row![dialog_button(
t!("Restore"),
Message::WebAutosaveRestore,
button::primary
)];
// Only worth its width once there is a batch to take in one click.
if pending > 1 {
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Restore all"),
Message::WebAutosaveRestoreAll,
button::secondary,
));
}
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Discard"),
Message::WebAutosaveDiscard,
button::danger,
));
body = body.push(buttons);
container(body)
.style(dialog_body_style)
.center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28])
.into()
}
#[cfg(not(target_arch = "wasm32"))]