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 Pages workflow stamps the cache version from the bundle hash and
fails if sw.js is missing or unstamped.

(cherry picked from commit 5f9b345e)

Ported onto main (#1717 review): the cache-version stamp moved from the
skipped build-web.sh wrapper into the Pages workflow; the tab-close hook
lands in main's on_tab_close, which also cancels pinned control requests.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent 2d2444fcfa
commit 6af8d8ba8c
37 files changed
+1170 -26

No files matched your search

+6
View File
@@ -36,6 +36,12 @@ pub(crate) mod web_autosave;
mod web_worker;
#[cfg(target_arch = "wasm32")]
pub(crate) mod web_drop;
// Same split as web_autosave: the ?file= deep-link parser (and its tests) are
// pure and compile everywhere; the fetch/open half is wasm-only.
pub(crate) mod web_link;
// Same split again: the session-memory serialization half (and its tests)
// compile everywhere; the localStorage half is wasm-only.
pub(crate) mod web_session;
pub mod windows_media;
pub mod xref;
pub mod xref_model;
+38
View File
@@ -123,6 +123,20 @@ pub(crate) fn recovered_name(drawing: &str) -> String {
format!("{stem}{RECOVERED_MARKER}.{ext}")
}
/// The open plan for a "Restore all" batch: one `(recovered tab name,
/// snapshot key)` pair per pending snapshot, in the pending list's order —
/// the same order the one-at-a-time prompts would have offered them
/// (newest first), and the order the batch's reads are fired in. The keys
/// travel unchanged: each snapshot is consumed only by its own successful
/// open, exactly like a single restore.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn restore_all_plan(entries: &[WebAutosaveEntry]) -> Vec<(String, String)> {
entries
.iter()
.map(|entry| (recovered_name(&entry.name), entry.name.clone()))
.collect()
}
/// Overwrite (or create) the snapshot for `name`. The optional thumbnail is
/// written alongside as `.thumb`; passing `None` removes any stale one.
#[cfg(target_arch = "wasm32")]
@@ -371,6 +385,30 @@ mod tests {
);
}
#[test]
fn restore_all_keeps_the_pending_order_and_maps_only_the_names() {
let entries = vec![
entry("new.dwg", 300.0),
entry("mid.dwg", 200.0),
entry("plan (recovered).dxf", 100.0),
];
let plan = restore_all_plan(&entries);
assert_eq!(
plan.iter().map(|(name, _)| name.as_str()).collect::<Vec<_>>(),
[
"new (recovered).dwg",
"mid (recovered).dwg",
"plan (recovered).dxf"
]
);
// Keys ride along unchanged — consumption stays keyed by the exact
// snapshot identity, never by the display name.
assert_eq!(
plan.iter().map(|(_, key)| key.as_str()).collect::<Vec<_>>(),
["new.dwg", "mid.dwg", "plan (recovered).dxf"]
);
}
fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry {
WebAutosaveEntry {
name: name.to_string(),
+475
View File
@@ -0,0 +1,475 @@
//! Web URL deep links (`?file=`).
//!
//! The web build had no way to share or embed a link that opens a drawing:
//! `location.search` was never read. `?file=<value>` now opens a drawing at
//! boot, where `<value>` is a URL-encoded `http(s)` URL to fetch or a bare
//! drawing name to look up in the OPFS recent copies.
//!
//! The pure half (query parsing, scheme gate, trailing-name extraction) is
//! target-independent and unit-tested on the native host, mirroring how
//! `web_autosave` keeps its comparison logic testable; the fetch/open half is
//! wasm-only. Security mirrors the remote-raster fetch: only `http(s)` is
//! fetched (never `file:`, `javascript:`, or any other scheme — refused with
//! a console warning), the body is read with the same capped streaming reader
//! (64 MB cap, 8 s deadline, honest network/CORS/size/status classification),
//! and the URL must end in a `.dwg`/`.dxf` name like a drop. Fetched bytes
//! then enter the guarded drop-open queue (`web_drop::enqueue_open`), so the
//! deep link cannot bypass the open slot guard or the parse + recovery
//! machinery every other open goes through.
/// What `?file=` asked for, after validation.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkTarget {
/// An absolute `http(s)` URL whose trailing path segment is a
/// `.dwg`/`.dxf` name; `name` is that decoded segment.
Url { url: String, name: String },
/// A bare drawing name (no scheme) — resolved against the OPFS recent
/// copies, the same store `open_recent_web` reads.
RecentName(String),
}
/// Why a present `?file=` value was refused.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkRefusal {
/// A scheme other than `http`/`https` (`file:`, `javascript:`, `data:`,
/// or a Windows drive-letter colon) — the same gate the remote-image
/// fetch applies; never fetched, only warned about.
Scheme,
/// Started with `http(s)://` but did not parse as an absolute URL.
BadUrl,
/// Parsed, but the trailing path segment is not a `.dwg`/`.dxf` name.
NotDrawing,
}
/// Result of parsing the page's `location.search` for the deep-link param.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedLink {
/// No (non-empty) `file` param — boot proceeds normally.
Absent,
/// A validated target to open.
Target(LinkTarget),
/// Present but refused; the raw value is kept for the console warning.
Ignored(LinkRefusal, String),
}
/// Extract and validate the `file` query parameter from a raw `search`
/// string (with or without the leading `?`). Values are form-decoded
/// (`%XX` and `+`), trimmed, and scheme-gated: `http(s)` URLs must parse and
/// end in a `.dwg`/`.dxf` path segment; any other scheme-like prefix is
/// refused (never dereferenced); anything else is treated as a bare drawing
/// name for the OPFS recent-copy lookup.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn parse_link(search: &str) -> ParsedLink {
let search = search.strip_prefix('?').unwrap_or(search);
// `form_urlencoded` yields `Cow<str>` pairs in order; the FIRST `file`
// param decides — a duplicated one must not turn into two opens.
let value = url::form_urlencoded::parse(search.as_bytes())
.find(|(key, _)| key == "file")
.map(|(_, value)| value.into_owned());
let Some(value) = value else {
return ParsedLink::Absent;
};
let value = value.trim();
if value.is_empty() {
return ParsedLink::Absent;
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let parsed = match url::Url::parse(value) {
Ok(parsed) => parsed,
Err(_) => return ParsedLink::Ignored(LinkRefusal::BadUrl, value.to_string()),
};
return match trailing_drawing_name(&parsed) {
Some(name) => ParsedLink::Target(LinkTarget::Url {
url: parsed.to_string(),
name,
}),
None => ParsedLink::Ignored(LinkRefusal::NotDrawing, value.to_string()),
};
}
if let Some(_scheme) = scheme_prefix(value) {
// Never dereference: the gate the remote-image fetch applies, so a
// `file:`/`javascript:`/drive-letter value dies here, warned only.
return ParsedLink::Ignored(LinkRefusal::Scheme, value.to_string());
}
ParsedLink::Target(LinkTarget::RecentName(value.to_string()))
}
/// The console warning for a refused param (mirrors the drop queues'
/// "ignored …" warnings, which also only reach the console).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn refusal_warning(refusal: LinkRefusal, value: &str) -> String {
match refusal {
LinkRefusal::Scheme => {
format!("ocs: ignored ?file=\"{value}\" (only http(s) URLs are fetched)")
}
LinkRefusal::BadUrl => {
format!("ocs: ignored ?file=\"{value}\" (not a valid absolute URL)")
}
LinkRefusal::NotDrawing => {
format!("ocs: ignored ?file=\"{value}\" (URL must end in a .dwg/.dxf name)")
}
}
}
/// RFC 3986 scheme prefix (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`)
/// of `value`, if any. A single letter before a colon matches (so
/// `C:\drawings\plan.dwg` is refused as a scheme, which is correct on the
/// web: a native path is meaningless there and must not become a fetch).
fn scheme_prefix(value: &str) -> Option<String> {
let bytes = value.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
return None;
}
let end = bytes.iter().position(|&byte| byte == b':')?;
let scheme = &value[..end];
let valid = !scheme.is_empty()
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
valid.then(|| scheme.to_string())
}
/// The URL's trailing path segment, percent-decoded, when it is a
/// `.dwg`/`.dxf` drawing name. Same extension convention as the drop filter
/// (`web_drop::is_drawing_name`): a dot-less segment is its own extension.
fn trailing_drawing_name(url: &url::Url) -> Option<String> {
// `Url::path()` keeps the percent-encoding, so decode it here; query and
// fragment are already excluded by `path()`.
let segment = percent_decode(url.path().rsplit('/').next().unwrap_or_default());
if !is_drawing_name(&segment) {
return None;
}
Some(segment)
}
/// Same convention as `web_drop::is_drawing_name` (which lives in the
/// wasm-gated drop module, so the rule is restated here for the pure parser).
fn is_drawing_name(name: &str) -> bool {
matches!(
name.rsplit('.')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"dwg" | "dxf"
)
}
/// Decode `%XX` escapes (NOT `+`, which path segments do not carry). Invalid
/// or truncated escapes pass through verbatim; undecodable bytes become
/// U+FFFD via lossy conversion — a name is display data, never a path.
fn percent_decode(value: &str) -> String {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
let escape = (bytes[index] == b'%' && index + 2 < bytes.len()).then(|| {
(hex(bytes[index + 1]), hex(bytes[index + 2]))
});
match escape {
Some((Some(high), Some(low))) => {
out.push(high * 16 + low);
index += 3;
}
_ => {
out.push(bytes[index]);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
// ── Web open path ──────────────────────────────────────────────────────────
/// How a deep link settled; the `WebLinkSettled` update arm surfaces it.
/// `pub` to match `WebAutosaveEntry` (public type in a `pub(crate)` module),
/// keeping the `Message` payload free of privacy warnings.
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Clone)]
pub enum LinkOutcome {
/// The bytes entered the guarded drop-open queue — the 50 ms poll opens
/// them exactly like a dropped file.
Queued,
/// Command-line notice (no drawing opened, nothing failed): the named
/// drawing is not in this browser's storage.
Info(String),
/// Command-line error: the fetch was refused or failed.
Error(String),
}
/// Resolve the boot deep link: fetch the URL with the same capped streaming
/// reader the remote-image fetch uses, or read the OPFS recent copy, then
/// queue the bytes for the guarded open poll. Errors render the honest
/// reason (network/CORS, HTTP status, size cap, deadline).
#[cfg(target_arch = "wasm32")]
pub(crate) async fn open_link(target: LinkTarget) -> LinkOutcome {
match target {
LinkTarget::Url { url, name } => {
match crate::scene::model::image_model::fetch_url_bytes(&url).await {
Ok(bytes) => enqueue_or_full(name, bytes),
Err(error) => LinkOutcome::Error(fetch_error_text(&url, error)),
}
}
LinkTarget::RecentName(name) => match super::web_recent::read(&name).await {
Ok(bytes) => enqueue_or_full(name, bytes),
Err(_) => LinkOutcome::Info(format!(
"\"{name}\" is not in this browser's recent drawings; open or drop it once and the link can reopen it."
)),
},
}
}
/// Hand the bytes to the guarded open queue (the same door a drop uses), or
/// report a full queue — the fetch succeeded, so the loss must be visible.
#[cfg(target_arch = "wasm32")]
fn enqueue_or_full(name: String, bytes: Vec<u8>) -> LinkOutcome {
if super::web_drop::enqueue_open(name.clone(), bytes) {
LinkOutcome::Queued
} else {
LinkOutcome::Error(format!(
"Deep-linked drawing \"{name}\" could not be opened: the open queue is full."
))
}
}
/// Plain-English twin of the remote-image failure notes (those strings are
/// built inline in the poll arm and mention images); the classification
/// itself is shared via `RemoteFetchError`.
#[cfg(target_arch = "wasm32")]
fn fetch_error_text(url: &str, error: crate::scene::model::image_model::RemoteFetchError) -> String {
use crate::scene::model::image_model::RemoteFetchError;
match error {
RemoteFetchError::Network => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the network request failed (offline, DNS, or CORS)."
),
RemoteFetchError::Status(status) => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the server returned HTTP {status}."
),
RemoteFetchError::TooLarge => format!(
"Deep-linked drawing \"{url}\" was not opened: it exceeds the 64 MB remote-file limit."
),
RemoteFetchError::Timeout => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the request timed out."
),
}
}
/// Whether `url` shares the page's origin. Unparseable URLs (or a missing
/// window) read as foreign, so the trust notice errs on the loud side.
#[cfg(target_arch = "wasm32")]
pub(crate) fn same_origin(url: &str) -> bool {
let Some(page) = web_sys::window().and_then(|window| window.location().origin().ok()) else {
return false;
};
match url::Url::parse(url) {
// `ascii_serialization` and `location.origin()` agree on omitting
// default ports, so the string comparison is exact.
Ok(parsed) => parsed.origin().ascii_serialization() == page,
Err(_) => false,
}
}
/// The origin to show in the trust notice — the URL's own origin, falling
/// back to the full URL when it cannot be rendered.
#[cfg(target_arch = "wasm32")]
pub(crate) fn display_origin(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.map(|parsed| parsed.origin().ascii_serialization())
.filter(|origin| !origin.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn url_target(search: &str) -> LinkTarget {
match parse_link(search) {
ParsedLink::Target(target) => target,
other => panic!("expected a target, got {other:?}"),
}
}
#[test]
fn absent_when_there_is_no_file_param() {
assert_eq!(parse_link(""), ParsedLink::Absent);
assert_eq!(parse_link("?"), ParsedLink::Absent);
assert_eq!(parse_link("?other=plan.dwg"), ParsedLink::Absent);
assert_eq!(parse_link("?file="), ParsedLink::Absent);
assert_eq!(parse_link("?file=%20"), ParsedLink::Absent);
// Only the exact lowercase key opens; `File=` is not a deep link.
assert_eq!(parse_link("?File=plan.dwg"), ParsedLink::Absent);
// The first occurrence wins — a duplicate must not open two files.
assert_eq!(
parse_link("?file=&file=plan.dwg"),
ParsedLink::Absent,
"an empty first value is absent, not skipped"
);
}
#[test]
fn http_urls_are_targets_with_their_trailing_name() {
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fplans%2Fsite.dwg"),
LinkTarget::Url {
url: "https://example.com/plans/site.dwg".to_string(),
name: "site.dwg".to_string()
}
);
// Unencoded URL in the query string works too (senders should
// encode, but browsers hand the raw text through).
assert_eq!(
url_target("?file=http://example.com/a.DXF"),
LinkTarget::Url {
url: "http://example.com/a.DXF".to_string(),
name: "a.DXF".to_string()
}
);
// Scheme check is case-insensitive; Url normalizes to lowercase.
assert_eq!(
url_target("?file=HTTPS://example.com/x.dwg"),
LinkTarget::Url {
url: "https://example.com/x.dwg".to_string(),
name: "x.dwg".to_string()
}
);
// Query and fragment never leak into the extracted name.
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fp.dwg%3Fv%3D1%23frag"),
LinkTarget::Url {
url: "https://example.com/p.dwg?v=1#frag".to_string(),
name: "p.dwg".to_string()
}
);
// Percent-encoded path segments decode into the tab name.
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fmy%2520plan.dwg"),
LinkTarget::Url {
// The param decodes to ".../my%20plan.dwg", which the URL
// keeps encoded in its path (a raw space is invalid there).
url: "https://example.com/my%20plan.dwg".to_string(),
name: "my plan.dwg".to_string()
}
);
}
#[test]
fn non_http_schemes_are_refused_not_fetched() {
for bad in [
"file:///C:/plan.dwg",
"javascript:alert(1)",
"data:text/plain,hello",
"ftp://example.com/plan.dwg",
"blob:https://example.com/uuid",
// A drive-letter colon is a valid single-letter scheme; a native
// path is meaningless on the web and must not become a fetch.
"C:\\drawings\\plan.dwg",
] {
assert_eq!(
parse_link(&format!("?file={bad}")),
ParsedLink::Ignored(LinkRefusal::Scheme, bad.to_string()),
"{bad} must be refused as a scheme"
);
}
// The refusal names the reason and echoes the value.
assert_eq!(
refusal_warning(LinkRefusal::Scheme, "javascript:alert(1)"),
"ocs: ignored ?file=\"javascript:alert(1)\" (only http(s) URLs are fetched)"
);
}
#[test]
fn http_urls_must_end_in_a_drawing_name() {
// No trailing segment, a directory URL, or a foreign extension.
for search in [
"?file=https%3A%2F%2Fexample.com%2F",
"?file=https%3A%2F%2Fexample.com%2Fplans%2F",
"?file=https%3A%2F%2Fexample.com%2Fplan.pdf",
"?file=https%3A%2F%2Fexample.com%2Fplan.dwg.txt",
// The extension check sees through dot-doubling tricks.
"?file=https%3A%2F%2Fexample.com%2F..dwgx",
] {
assert!(
matches!(parse_link(search), ParsedLink::Ignored(LinkRefusal::NotDrawing, _)),
"{search} must be refused as not a drawing"
);
}
// A dot-less segment is its own extension, mirroring the drop filter.
assert!(matches!(
url_target("?file=https%3A%2F%2Fexample.com%2FDWG"),
LinkTarget::Url { name, .. } if name == "DWG"
));
}
#[test]
fn malformed_http_urls_are_refused() {
assert!(matches!(
parse_link("?file=http%3A%2F%2F"),
ParsedLink::Ignored(LinkRefusal::BadUrl, _)
));
}
#[test]
fn bare_names_target_the_recent_store() {
assert_eq!(
url_target("?file=plan.dwg"),
LinkTarget::RecentName("plan.dwg".to_string())
);
// Encoded spaces and non-ASCII decode into the name.
assert_eq!(
url_target("?file=my+plan.dxf"),
LinkTarget::RecentName("my plan.dxf".to_string())
);
assert_eq!(
url_target("?file=%C3%A7izim.dwg"),
LinkTarget::RecentName("çizim.dwg".to_string())
);
// No scheme and no colon, but slashes stay a name — the recent store
// keys by the hashed name, so there is nothing to traverse.
assert_eq!(
url_target("?file=sub%2Fplan.dwg"),
LinkTarget::RecentName("sub/plan.dwg".to_string())
);
}
#[test]
fn percent_decode_only_touches_valid_escapes() {
assert_eq!(percent_decode("a%20b"), "a b");
assert_eq!(percent_decode("%2Fd%2E"), "/d.");
// Truncated and invalid escapes pass through verbatim.
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
assert_eq!(percent_decode("%2"), "%2");
// Non-ASCII text survives untouched; bad UTF-8 escapes go lossy.
assert_eq!(percent_decode("çizim.dwg"), "çizim.dwg");
assert_eq!(percent_decode("%ff"), "\u{fffd}");
}
#[test]
fn scheme_prefix_matches_rfc3986_shape_only() {
assert_eq!(scheme_prefix("https://x"), Some("https".to_string()));
assert_eq!(scheme_prefix("c:\\x"), Some("c".to_string()));
assert_eq!(
scheme_prefix("x1+2-3.4:rest"),
Some("x1+2-3.4".to_string())
);
// A colon after a non-scheme character (space, slash, digit start)
// is not a scheme.
assert_eq!(scheme_prefix("plan.dwg"), None);
assert_eq!(scheme_prefix("a b:c"), None);
assert_eq!(scheme_prefix("1http://x"), None);
assert_eq!(scheme_prefix("no-colon"), None);
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Web session memory: which drawings were open (and which was active) the
//! last time a save/open/close touched the tab list.
//!
//! Deliberately NOT a session-restore engine: nothing ever auto-opens from
//! this list. The next boot's crash-recovery flow reads it only to show what
//! the session had and — once restores complete — to put the focus back on
//! the tab the session remembered as active. A small `Vec<String>` + index
//! in localStorage, rewritten in full on every save/open/close, is the whole
//! cost.
use serde::{Deserialize, Serialize};
/// localStorage key, next to `opencadstudio.settings`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) const SESSION_KEY: &str = "opencadstudio.session";
/// The remembered tab list. `active` indexes `tabs`; construction and
/// parsing both clamp it so a stale index can never point outside the list.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WebSession {
pub tabs: Vec<String>,
pub active: usize,
}
impl WebSession {
pub fn new(tabs: Vec<String>, active: usize) -> Self {
Self {
active: active.min(tabs.len().saturating_sub(1)),
tabs,
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
/// Forgiving parse: a missing or out-of-range `active` clamps instead of
/// failing (the list is advisory memory, never load-bearing).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
fn from_json(json: &str) -> Option<Self> {
let session: Self = serde_json::from_str(json).ok()?;
Some(Self::new(session.tabs, session.active))
}
}
/// Remember `tabs`/`active` for the next boot. Best-effort: unavailable or
/// read-only storage silently skips the write.
#[cfg(target_arch = "wasm32")]
pub fn persist(tabs: &[String], active: usize) {
if cfg!(test) {
return;
}
let session = WebSession::new(tabs.to_vec(), active);
if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten())
{
let _ = storage.set_item(SESSION_KEY, &session.to_json());
}
}
/// What the previous session had open, if it ever recorded anything.
#[cfg(target_arch = "wasm32")]
pub fn load() -> Option<WebSession> {
let json = web_sys::window()
.and_then(|window| window.local_storage().ok().flatten())
.and_then(|storage| storage.get_item(SESSION_KEY).ok().flatten())?;
WebSession::from_json(&json)
}
// Pure logic only — the localStorage half needs a browser and is exercised
// by hand (and by every web boot's restore flow).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_session_round_trips_through_json() {
let session = WebSession::new(
vec!["plan.dwg".to_string(), "sketch.dxf".to_string()],
1,
);
let restored = WebSession::from_json(&session.to_json()).expect("must parse");
assert_eq!(restored, session);
assert_eq!(restored.tabs, ["plan.dwg", "sketch.dxf"]);
assert_eq!(restored.active, 1);
}
#[test]
fn an_out_of_range_active_index_clamps_instead_of_failing() {
// Construction clamps…
assert_eq!(WebSession::new(vec!["a.dwg".to_string()], 9).active, 0);
// …and so does a parse of stale or hostile JSON, both directions.
let over = WebSession::from_json(r#"{"tabs":["a.dwg","b.dwg"],"active":99}"#)
.expect("must parse");
assert_eq!(over.active, 1);
let under = WebSession::from_json(r#"{"tabs":["a.dwg"],"active":0}"#).unwrap();
assert_eq!(under.active, 0);
}
#[test]
fn garbage_is_rejected_and_an_empty_list_is_fine() {
assert!(WebSession::from_json("").is_none());
assert!(WebSession::from_json("not json").is_none());
assert!(WebSession::from_json("{}").is_none()); // missing `tabs` field
// A session whose every tab closed is still valid memory (it says
// the crash lost nothing that was visibly open).
let empty = WebSession::from_json(r#"{"tabs":[],"active":0}"#).unwrap();
assert!(empty.tabs.is_empty());
assert_eq!(empty.active, 0);
}
#[test]
fn duplicate_names_are_kept_as_is() {
// Two tabs may share a display name on the web; the memory list
// records the list as it was, de-duplication is not its job.
let session = WebSession::new(
vec!["a.dwg".to_string(), "a.dwg".to_string()],
1,
);
assert_eq!(
WebSession::from_json(&session.to_json()).unwrap(),
session
);
}
}