From 6af8d8ba8cf9e3ecf769c7aaea50c8aa027cbd4c Mon Sep 17 00:00:00 2001 From: Afonsop Date: Mon, 12 Oct 2026 11:33:44 +0300 Subject: [PATCH] feat: restore-all session recovery, ?file= deep links, offline PWA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- .github/workflows/pages.yml | 10 + Cargo.toml | 3 + locales/ar-SA/opencadstudio.ftl | 3 + locales/bg-BG/opencadstudio.ftl | 3 + locales/cs-CZ/opencadstudio.ftl | 3 + locales/de-DE/opencadstudio.ftl | 3 + locales/el-GR/opencadstudio.ftl | 3 + locales/en-US/opencadstudio.ftl | 3 + locales/es-ES/opencadstudio.ftl | 3 + locales/fi-FI/opencadstudio.ftl | 3 + locales/fr-FR/opencadstudio.ftl | 3 + locales/hi-IN/opencadstudio.ftl | 3 + locales/hu-HU/opencadstudio.ftl | 3 + locales/it-IT/opencadstudio.ftl | 3 + locales/ja-JP/opencadstudio.ftl | 3 + locales/ko-KR/opencadstudio.ftl | 3 + locales/nl-NL/opencadstudio.ftl | 3 + locales/pl-PL/opencadstudio.ftl | 3 + locales/pt-BR/opencadstudio.ftl | 3 + locales/ru-RU/opencadstudio.ftl | 3 + locales/tr-TR/opencadstudio.ftl | 3 + locales/zh-CN/opencadstudio.ftl | 3 + locales/zh-TW/opencadstudio.ftl | 3 + src/app/mod.rs | 77 ++++++ src/app/update/command.rs | 4 + src/app/update/dialog.rs | 4 + src/app/update/file.rs | 118 +++++++- src/app/update/mod.rs | 24 ++ src/app/view/modal.rs | 85 ++++-- src/io/mod.rs | 6 + src/io/web_autosave.rs | 38 +++ src/io/web_link.rs | 475 ++++++++++++++++++++++++++++++++ src/io/web_session.rs | 126 +++++++++ src/locale_catalog.rs | 3 + src/scene/model/image_model.rs | 8 +- web-app.html | 16 ++ web/sw.js | 136 +++++++++ 37 files changed, 1170 insertions(+), 26 deletions(-) create mode 100644 src/io/web_link.rs create mode 100644 src/io/web_session.rs create mode 100644 web/sw.js diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9f9c4f4bd..286667b34 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -109,6 +109,16 @@ jobs: - name: Build web bundle run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html + - name: Stamp the service worker cache version + # web/sw.js ships a placeholder cache name; each deploy gets one + # derived from the bundle contents, so the new worker drops the + # previous deploy's cached assets when it activates. + run: | + test -f dist/app/sw.js + hash=$(find dist/app -type f ! -name sw.js -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c 1-12) + sed -i "s/^const CACHE_VERSION = \"[^\"]*\";/const CACHE_VERSION = \"$hash\";/" dist/app/sw.js + grep -q "^const CACHE_VERSION = \"$hash\";" dist/app/sw.js + - name: Check out current website uses: actions/checkout@v4 with: diff --git a/Cargo.toml b/Cargo.toml index 75b0e8189..4a068c28e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,6 +184,9 @@ web-sys = { version = "=0.3.85", features = [ "Event", "ReadableStreamDefaultReader", "ReadableStream", + # The ?file= boot deep link reads location.search and compares the link's + # origin against the page's (io::web_link). + "Location", ] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 1471c703d..88fdeb4c0 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2412,6 +2412,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT الموضع [استبدال/بادئة/لاحقة] <استبدال>: file = + .restore-all = استعادة الكل + .restoring-drawing-s = جارٍ استعادة __ocs_fmt_0__ رسم… + .session-had-open-drawing-s = كانت الجلسة تضم __ocs_fmt_0__ رسمًا مفتوحًا. .already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود. .image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = التحليل __ocs_fmt_0__ ملث · التنظيف __ocs_fmt_1__ ملث · الذاكرة المؤقتة __ocs_fmt_2__ ملث · المرجع الخارجي __ocs_fmt_3__ ملث · الإجمالي __ocs_fmt_4__ ملث diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 3f383ac42..e734713bd 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT поставяне [Замяна/Префикс/Суфикс] <Замяна>: file = + .restore-all = Възстанови всички + .restoring-drawing-s = Възстановяване на __ocs_fmt_0__ чертеж(а)… + .session-had-open-drawing-s = Сесията е имала __ocs_fmt_0__ отворени чертежа. .already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен. .image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = анализ __ocs_fmt_0__ms · изчистване __ocs_fmt_1__ms · кешове __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общо __ocs_fmt_4__ms diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 978750b6c..2fd02801b 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Umístění TCOUNT [přepsat/předpona/přípona] : file = + .restore-all = Obnovit vše + .restoring-drawing-s = Obnovování __ocs_fmt_0__ výkres(ů)… + .session-had-open-drawing-s = Relace měla otevřeno __ocs_fmt_0__ výkres(ů). .already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován. .image-refusal-unknown-size = „__ocs_fmt_0__“ nešlo změřit ze záhlaví a jeho dekódování by překročilo limit __ocs_fmt_1__ MB tohoto sestavení — nelze připojit. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analyzovat __ocs_fmt_0__ms · vyčistit __ocs_fmt_1__ms · mezipaměti __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · celkem __ocs_fmt_4__ms diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index d46247aee..18939b6ba 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>: file = + .restore-all = Alle wiederherstellen + .restoring-drawing-s = __ocs_fmt_0__ Zeichnung(en) werden wiederhergestellt… + .session-had-open-drawing-s = Die Sitzung hatte __ocs_fmt_0__ geöffnete Zeichnung(en). .already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten. .image-refusal-unknown-size = „__ocs_fmt_0__“ konnte anhand des Headers nicht vermessen werden, und das Dekodieren überschreitet das __ocs_fmt_1__-MB-Limit dieses Builds — kann nicht angehängt werden. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analysiere __ocs_fmt_0__ms · Säubere __ocs_fmt_1__ms · Puffer __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · gesamt __ocs_fmt_4__ms diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 8ed57c8cd..67f8deb20 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT τοποθέτηση [Αντικατάσταση/Πρόθεμα/Κατάληξη] <Αντικατάσταση>: file = + .restore-all = Επαναφορά όλων + .restoring-drawing-s = Επαναφορά __ocs_fmt_0__ σχέδιο(α)… + .session-had-open-drawing-s = Η συνεδρία είχε __ocs_fmt_0__ ανοιχτά σχέδια. .already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο. .image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ανάλυση __ocs_fmt_0__ms · εκκαθάριση __ocs_fmt_1__ms · κρυφές μνήμες __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · σύνολο __ocs_fmt_4__ms diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 649a089b3..6d9791fa9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2403,6 +2403,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT placement [Overwrite/Prefix/Suffix] : file = + .restore-all = Restore all + .restoring-drawing-s = Restoring __ocs_fmt_0__ drawing(s)… + .session-had-open-drawing-s = Session had __ocs_fmt_0__ open drawing(s). .already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept. .image-refusal-unknown-size = "__ocs_fmt_0__" could not be sized from its header and decoding it exceeded the __ocs_fmt_1__ MB image limit of this build — it cannot be attached. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · purge __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · total __ocs_fmt_4__ms diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a89f60e35..a7af6436f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT colocación [Sobrescribir/Prefijo/Sufijo] : file = + .restore-all = Restaurar todo + .restoring-drawing-s = Restaurando __ocs_fmt_0__ dibujos… + .session-had-open-drawing-s = La sesión tenía __ocs_fmt_0__ dibujos abiertos. .already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente. .image-refusal-unknown-size = "__ocs_fmt_0__" no pudo medirse por su cabecera y decodificarlo excedería el límite de __ocs_fmt_1__ MB de esta build — no se puede adjuntar. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análisis __ocs_fmt_0__ ms · limpieza __ocs_fmt_1__ ms · cachés __ocs_fmt_2__ ms · ref. ext. __ocs_fmt_3__ ms · total __ocs_fmt_4__ ms diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 7ea76797c..6dbea0f9c 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT-sijoittelu [Korvaa/etuliite/liite] : file = + .restore-all = Palauta kaikki + .restoring-drawing-s = Palautetaan __ocs_fmt_0__ piirustusta… + .session-had-open-drawing-s = Istunnossa oli __ocs_fmt_0__ avointa piirustusta. .already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin. .image-refusal-unknown-size = Kohteen "__ocs_fmt_0__" kokoa ei voitu mitata otsakkeesta ja sen purkaminen ylittäisi tämän käännöksen __ocs_fmt_1__ MB kuvarajan — sitä ei voi liittää. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = jäsentää __ocs_fmt_0__ms · tyhjentää __ocs_fmt_1__ms · välimuistit __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · yhteensä __ocs_fmt_4__ms diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0340544c1..2383822d8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Position [Remplacer/Préfixe/Suffixe] : file = + .restore-all = Tout restaurer + .restoring-drawing-s = Restauration de __ocs_fmt_0__ dessin(s)… + .session-had-open-drawing-s = La session avait __ocs_fmt_0__ dessin(s) ouvert(s). .already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé. .image-refusal-unknown-size = "__ocs_fmt_0__" n’a pas pu être mesuré par son en-tête et son décodage dépasserait la limite de __ocs_fmt_1__ Mo de cette version — impossible de l’attacher. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = Analyse : __ocs_fmt_0__ ms, purge : __ocs_fmt_1__ ms, caches : __ocs_fmt_2__ ms, références externes : __ocs_fmt_3__ ms, total : __ocs_fmt_4__ ms diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 83b952608..23ef882d5 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2388,6 +2388,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT स्थान [अधिलेखित/उपसर्ग/प्रत्यय] <अधिलेखित>: file = + .restore-all = सभी पुनर्स्थापित करें + .restoring-drawing-s = __ocs_fmt_0__ ड्रॉइंग पुनर्स्थापित की जा रही हैं… + .session-had-open-drawing-s = सत्र में __ocs_fmt_0__ ड्रॉइंग खुली थीं। .already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई। .image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता। .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = पार्स __ocs_fmt_0__ms · purge __ocs_fmt_1__Ms · कैश __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · कुल __ocs_fmt_4__ms diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d54254d94..c2d2672a7 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2412,6 +2412,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT elhelyezés [Overwrite (Felülírás) / Prefix (Előtag) / Suffix (Utótag)] : file = + .restore-all = Összes visszaállítása + .restoring-drawing-s = __ocs_fmt_0__ rajz(ok) visszaállítása… + .session-had-open-drawing-s = A munkamenetben __ocs_fmt_0__ rajz volt megnyitva. .already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve. .image-refusal-unknown-size = A(z) "__ocs_fmt_0__" nem mérhető a fejlécéből, és dekódolása meghaladná ezen build __ocs_fmt_1__ MB-os képhatárát — nem csatolható. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = elemzése __ocs_fmt_0__ms · __ocs_fmt_1__ms törlése · gyorsítótárak __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · összesen __ocs_fmt_4__ms diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index cb599f7a1..7a774b884 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Posizionamento TCOUNT [Sovrascrivi/Prefisso/Suffisso] : file = + .restore-all = Ripristina tutto + .restoring-drawing-s = Ripristino di __ocs_fmt_0__ disegno/i… + .session-had-open-drawing-s = La sessione aveva __ocs_fmt_0__ disegni aperti. .already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto. .image-refusal-unknown-size = "__ocs_fmt_0__" non è stato misurabile dall’intestazione e decodificarlo supererebbe il limite di __ocs_fmt_1__ MB di questa build — impossibile allegarlo. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizza __ocs_fmt_0__ms · elimina __ocs_fmt_1__ms · memorizza nella cache __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totale __ocs_fmt_4__ms diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index cd5e7d900..a82fcc7e4 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2390,6 +2390,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 配置 [Overwrite/Prefix/Suffix] : file = + .restore-all = すべて復元 + .restoring-drawing-s = __ocs_fmt_0__ 件の図面を復元しています… + .session-had-open-drawing-s = セッションでは __ocs_fmt_0__ 件の図面が開いていました。 .already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。 .image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ms · パージ __ocs_fmt_1__ms · キャッシュ __ocs_fmt_2__ms · 外部参照 __ocs_fmt_3__ms · 合計 __ocs_fmt_4__ms diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index f09d9cd13..4b1639631 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 배치 [덮어쓰기/접두사/접미사] : file = + .restore-all = 모두 복원 + .restoring-drawing-s = __ocs_fmt_0__ 도면을 복원하는 중… + .session-had-open-drawing-s = 세션에서 __ocs_fmt_0__ 도면이 열려 있었습니다. .already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다. .image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = __ocs_fmt_0__ms 구문 분석 · __ocs_fmt_1__ms 제거 · 캐시 __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · 총 __ocs_fmt_4__ms diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index c06f62e67..3f2869168 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] : file = + .restore-all = Alles herstellen + .restoring-drawing-s = __ocs_fmt_0__ tekening(en) worden hersteld… + .session-had-open-drawing-s = De sessie had __ocs_fmt_0__ tekening(en) open. .already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard. .image-refusal-unknown-size = "__ocs_fmt_0__" kon niet via de header gemeten worden en decoderen overschrijdt de __ocs_fmt_1__ MB-limiet van deze build — kan niet worden toegevoegd. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ontleed __ocs_fmt_0__ms · zuivering __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totaal __ocs_fmt_4__ms diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 53c7444d7..2a9252bcb 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Umieszczenie TCOUNT [Nadpisanie/Przedrostek/Sufiks] : file = + .restore-all = Przywróć wszystkie + .restoring-drawing-s = Przywracanie __ocs_fmt_0__ rysunków… + .session-had-open-drawing-s = Sesja miała __ocs_fmt_0__ otwartych rysunków. .already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik. .image-refusal-unknown-size = „__ocs_fmt_0__” nie dał się zmierzyć z nagłówka, a jego dekodowanie przekroczy limit __ocs_fmt_1__ MB tej kompilacji — nie można go dołączyć. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizuj __ocs_fmt_0__ms · usuwaj __ocs_fmt_1__ms · pamięci podręczne __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · łącznie __ocs_fmt_4__ms diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 2cc214d18..39e6705f7 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT posicionamento [Sobrescrever/Prefixo/Sufixo] : file = + .restore-all = Restaurar tudo + .restoring-drawing-s = Restaurando __ocs_fmt_0__ desenhos… + .session-had-open-drawing-s = A sessão tinha __ocs_fmt_0__ desenhos abertos. .already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido. .image-refusal-unknown-size = "__ocs_fmt_0__" não pôde ser medido pelo cabeçalho e a sua descodificação excedeu o limite de __ocs_fmt_1__ MB desta build — não pode ser anexado. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análise __ocs_fmt_0__ms · limpeza __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · ref. externa __ocs_fmt_3__ms · total __ocs_fmt_4__ms diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index da97b93c5..a7e1b4e7e 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2403,6 +2403,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Размещение [Заменить/Префикс/Суффикс] <Заменить>: file = + .restore-all = Восстановить все + .restoring-drawing-s = Восстановление __ocs_fmt_0__ чертежа(ей)… + .session-had-open-drawing-s = В сессии было открыто __ocs_fmt_0__ чертежа(ей). .already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён. .image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · чистка __ocs_fmt_1__ms · кэши __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общая __ocs_fmt_4__ms diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 83f572098..24cdc64ac 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2384,6 +2384,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Yerleştirme [Üzerine yaz/Önek/Sonek] <Üzerine yaz>: file = + .restore-all = Tümünü geri yükle + .restoring-drawing-s = __ocs_fmt_0__ çizim geri yükleniyor… + .session-had-open-drawing-s = Oturumda __ocs_fmt_0__ açık çizim vardı. .already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu. .image-refusal-unknown-size = "__ocs_fmt_0__" başlığından ölçülemedi ve kodu çözülürse bu sürümün __ocs_fmt_1__ MB görüntü limiti aşılır — eklenemez. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ayrıştırma __ocs_fmt_0__ ms · temizleme __ocs_fmt_1__ ms · önbellekler __ocs_fmt_2__ ms · xref __ocs_fmt_3__ ms · toplam __ocs_fmt_4__ ms diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 373c75975..96cd99051 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2387,6 +2387,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆盖/前缀/后缀] <覆盖>: file = + .restore-all = 全部恢复 + .restoring-drawing-s = 正在恢复 __ocs_fmt_0__ 绘图… + .session-had-open-drawing-s = 会话中有 __ocs_fmt_0__ 个打开的绘图。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。 .image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 缓存 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 总计 __ocs_fmt_4__ 毫秒 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 9dc7d60cd..ffa1109f3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆蓋/前綴/後綴] <覆蓋>: file = + .restore-all = 全部恢復 + .restoring-drawing-s = 正在恢復 __ocs_fmt_0__ 繪圖… + .session-had-open-drawing-s = 工作階段中有 __ocs_fmt_0__ 個開啟的繪圖。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。 .image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 快取 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 總計 __ocs_fmt_4__ 毫秒 diff --git a/src/app/mod.rs b/src/app/mod.rs index eeab4a304..e918409ba 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, + /// 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, /// 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), + /// 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 = { + 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, ]), ) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 9e13601a1..9ba516246 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -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() } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 12f4232a1..f6691cacf 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -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) => { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bc662022a..c18815d09 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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, String>, ) -> Task { + 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 { + // 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::>(); + 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` + 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 = 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) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index dfc7cf614..7ccce051f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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(), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 80e38ec3e..8e297f43f 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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, 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"))] diff --git a/src/io/mod.rs b/src/io/mod.rs index 5bfb1424c..a25434e25 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -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; diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs index 775ebd21e..5939d2ea9 100644 --- a/src/io/web_autosave.rs +++ b/src/io/web_autosave.rs @@ -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::>(), + [ + "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::>(), + ["new.dwg", "mid.dwg", "plan (recovered).dxf"] + ); + } + fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry { WebAutosaveEntry { name: name.to_string(), diff --git a/src/io/web_link.rs b/src/io/web_link.rs new file mode 100644 index 000000000..4c6423979 --- /dev/null +++ b/src/io/web_link.rs @@ -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=` now opens a drawing at +//! boot, where `` 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` 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 { + 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 { + // `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 { + 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) -> 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 { + 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); + } +} diff --git a/src/io/web_session.rs b/src/io/web_session.rs new file mode 100644 index 000000000..673d45c16 --- /dev/null +++ b/src/io/web_session.rs @@ -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` + 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, + pub active: usize, +} + +impl WebSession { + pub fn new(tabs: Vec, 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 { + 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 { + 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 + ); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 4d0e403df..936d189c9 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -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")), diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index f01967d9c..1b03ba9ed 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -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, 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, 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 { diff --git a/web-app.html b/web-app.html index 612262827..b66bb39f0 100644 --- a/web-app.html +++ b/web-app.html @@ -45,6 +45,7 @@ +
@@ -53,6 +54,21 @@
Loading…
+