forked from 1b-agent/opencadstudio-upstream
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:
1 parent
2d2444fcfa
commit
6af8d8ba8c
37 files changed
+1170
-26
No files matched your search
@@ -927,6 +927,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
|
||||
@@ -2346,6 +2365,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")]
|
||||
@@ -2353,6 +2377,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,
|
||||
@@ -4524,6 +4554,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(),
|
||||
@@ -5119,6 +5155,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([
|
||||
@@ -5129,6 +5205,7 @@ impl OpenCADStudio {
|
||||
discussions,
|
||||
thumbs_fetch,
|
||||
autosave_scan,
|
||||
deep_link,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ impl OpenCADStudio {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,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
@@ -2248,6 +2248,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);
|
||||
@@ -2803,6 +2807,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;
|
||||
@@ -3962,12 +3969,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();
|
||||
@@ -4004,6 +4015,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.
|
||||
@@ -4097,14 +4146,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) {
|
||||
|
||||
@@ -481,6 +481,14 @@ impl OpenCADStudio {
|
||||
self.graphic_attributes.retain_selection(&selected);
|
||||
}
|
||||
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.
|
||||
@@ -1401,11 +1409,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
@@ -1817,8 +1817,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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -2070,36 +2077,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"))]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2703,6 +2703,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"SAVEALL: saved {saved} drawing(s){}." => Some(("file", "saveall-saved-saved-drawing-s")),
|
||||
"SAVEALL: {} already has a save running" => Some(("file", "saveall-already-has-a-save-running")),
|
||||
"SAVEALL: {} failed: {e}" => Some(("file", "saveall-failed-e")),
|
||||
"Restore all" => Some(("file", "restore-all")),
|
||||
"Restoring {} drawing(s)…" => Some(("file", "restoring-drawing-s")),
|
||||
"Session had {count} open drawing(s)." => Some(("file", "session-had-open-drawing-s")),
|
||||
"At most {MAX_EXTRACTION_SOURCES} drawings can be added in the browser." => Some(("file", "extraction-max-sources-web")),
|
||||
"\"{}\" is already open in another tab. On the web both tabs share one stored copy — the last save overwrites the other." => Some(("file", "web-already-open-shared-copy")),
|
||||
"SAVETIME autosave interval in minutes (0 = off):" => Some(("styles", "savetime-autosave-interval-in-minutes-0-off")),
|
||||
|
||||
@@ -1278,7 +1278,9 @@ mod web_fetch {
|
||||
/// The actual request. `GET url` with an abort signal armed at
|
||||
/// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the
|
||||
/// declared `Content-Length` first and the received length second.
|
||||
async fn fetch_once(url: &str) -> Result<Vec<u8>, RemoteFetchError> {
|
||||
/// `pub(crate)` so the `?file=` deep link can fetch its drawing through
|
||||
/// this exact reader (see `io::web_link`).
|
||||
pub(crate) async fn fetch_once(url: &str) -> Result<Vec<u8>, RemoteFetchError> {
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
@@ -1369,6 +1371,10 @@ pub use web_fetch::{
|
||||
take_outcome as take_remote_fetch_outcome, take_overflowed,
|
||||
RemoteFetchError, RemoteFetchOutcome,
|
||||
};
|
||||
// The capped streaming reader itself, shared with the `?file=` deep-link
|
||||
// fetch (`io::web_link`) so both doors enforce the same 64 MB / 8 s bounds.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) use web_fetch::fetch_once as fetch_url_bytes;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
|
||||
Reference in new issue
Block a user