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

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

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

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

(cherry picked from commit 5f9b345e)

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

No files matched your search

+10
View File
@@ -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:
+3
View File
@@ -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"] }
+3
View File
@@ -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__ ملث
+3
View File
@@ -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
+3
View File
@@ -2399,6 +2399,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = Umístění TCOUNT [přepsat/předpona/přípona] <Overwrite>:
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
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -2403,6 +2403,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT placement [Overwrite/Prefix/Suffix] <Overwrite>:
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
+3
View File
@@ -2400,6 +2400,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT colocación [Sobrescribir/Prefijo/Sufijo] <Sobrescribir>:
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
+3
View File
@@ -2399,6 +2399,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT-sijoittelu [Korvaa/etuliite/liite] <Overwrite>:
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
+3
View File
@@ -2400,6 +2400,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Position [Remplacer/Préfixe/Suffixe] <Remplacer> :
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
+3
View File
@@ -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
+3
View File
@@ -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)] <Overwrite>:
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
+3
View File
@@ -2399,6 +2399,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = Posizionamento TCOUNT [Sovrascrivi/Prefisso/Suffisso] <Overwrite>:
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
+3
View File
@@ -2390,6 +2390,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 配置 [Overwrite/Prefix/Suffix] <Overwrite>:
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
+3
View File
@@ -2399,6 +2399,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 배치 [덮어쓰기/접두사/접미사] <Overwrite>:
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
+3
View File
@@ -2400,6 +2400,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] <Overschrijven>:
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
+3
View File
@@ -2399,6 +2399,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = Umieszczenie TCOUNT [Nadpisanie/Przedrostek/Sufiks] <Overwrite>:
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
+3
View File
@@ -2400,6 +2400,9 @@ command =
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT posicionamento [Sobrescrever/Prefixo/Sufixo] <Sobrescrever>:
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
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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__ 毫秒
+3
View File
@@ -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__ 毫秒
+77
View File
@@ -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,
]),
)
}
+4
View File
@@ -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()
}
+4
View File
@@ -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
View File
@@ -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) {
+24
View File
@@ -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
View File
@@ -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"))]
+6
View File
@@ -36,6 +36,12 @@ pub(crate) mod web_autosave;
mod web_worker;
#[cfg(target_arch = "wasm32")]
pub(crate) mod web_drop;
// Same split as web_autosave: the ?file= deep-link parser (and its tests) are
// pure and compile everywhere; the fetch/open half is wasm-only.
pub(crate) mod web_link;
// Same split again: the session-memory serialization half (and its tests)
// compile everywhere; the localStorage half is wasm-only.
pub(crate) mod web_session;
pub mod windows_media;
pub mod xref;
pub mod xref_model;
+38
View File
@@ -123,6 +123,20 @@ pub(crate) fn recovered_name(drawing: &str) -> String {
format!("{stem}{RECOVERED_MARKER}.{ext}")
}
/// The open plan for a "Restore all" batch: one `(recovered tab name,
/// snapshot key)` pair per pending snapshot, in the pending list's order —
/// the same order the one-at-a-time prompts would have offered them
/// (newest first), and the order the batch's reads are fired in. The keys
/// travel unchanged: each snapshot is consumed only by its own successful
/// open, exactly like a single restore.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn restore_all_plan(entries: &[WebAutosaveEntry]) -> Vec<(String, String)> {
entries
.iter()
.map(|entry| (recovered_name(&entry.name), entry.name.clone()))
.collect()
}
/// Overwrite (or create) the snapshot for `name`. The optional thumbnail is
/// written alongside as `.thumb`; passing `None` removes any stale one.
#[cfg(target_arch = "wasm32")]
@@ -371,6 +385,30 @@ mod tests {
);
}
#[test]
fn restore_all_keeps_the_pending_order_and_maps_only_the_names() {
let entries = vec![
entry("new.dwg", 300.0),
entry("mid.dwg", 200.0),
entry("plan (recovered).dxf", 100.0),
];
let plan = restore_all_plan(&entries);
assert_eq!(
plan.iter().map(|(name, _)| name.as_str()).collect::<Vec<_>>(),
[
"new (recovered).dwg",
"mid (recovered).dwg",
"plan (recovered).dxf"
]
);
// Keys ride along unchanged — consumption stays keyed by the exact
// snapshot identity, never by the display name.
assert_eq!(
plan.iter().map(|(_, key)| key.as_str()).collect::<Vec<_>>(),
["new.dwg", "mid.dwg", "plan (recovered).dxf"]
);
}
fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry {
WebAutosaveEntry {
name: name.to_string(),
+475
View File
@@ -0,0 +1,475 @@
//! Web URL deep links (`?file=`).
//!
//! The web build had no way to share or embed a link that opens a drawing:
//! `location.search` was never read. `?file=<value>` now opens a drawing at
//! boot, where `<value>` is a URL-encoded `http(s)` URL to fetch or a bare
//! drawing name to look up in the OPFS recent copies.
//!
//! The pure half (query parsing, scheme gate, trailing-name extraction) is
//! target-independent and unit-tested on the native host, mirroring how
//! `web_autosave` keeps its comparison logic testable; the fetch/open half is
//! wasm-only. Security mirrors the remote-raster fetch: only `http(s)` is
//! fetched (never `file:`, `javascript:`, or any other scheme — refused with
//! a console warning), the body is read with the same capped streaming reader
//! (64 MB cap, 8 s deadline, honest network/CORS/size/status classification),
//! and the URL must end in a `.dwg`/`.dxf` name like a drop. Fetched bytes
//! then enter the guarded drop-open queue (`web_drop::enqueue_open`), so the
//! deep link cannot bypass the open slot guard or the parse + recovery
//! machinery every other open goes through.
/// What `?file=` asked for, after validation.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkTarget {
/// An absolute `http(s)` URL whose trailing path segment is a
/// `.dwg`/`.dxf` name; `name` is that decoded segment.
Url { url: String, name: String },
/// A bare drawing name (no scheme) — resolved against the OPFS recent
/// copies, the same store `open_recent_web` reads.
RecentName(String),
}
/// Why a present `?file=` value was refused.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkRefusal {
/// A scheme other than `http`/`https` (`file:`, `javascript:`, `data:`,
/// or a Windows drive-letter colon) — the same gate the remote-image
/// fetch applies; never fetched, only warned about.
Scheme,
/// Started with `http(s)://` but did not parse as an absolute URL.
BadUrl,
/// Parsed, but the trailing path segment is not a `.dwg`/`.dxf` name.
NotDrawing,
}
/// Result of parsing the page's `location.search` for the deep-link param.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedLink {
/// No (non-empty) `file` param — boot proceeds normally.
Absent,
/// A validated target to open.
Target(LinkTarget),
/// Present but refused; the raw value is kept for the console warning.
Ignored(LinkRefusal, String),
}
/// Extract and validate the `file` query parameter from a raw `search`
/// string (with or without the leading `?`). Values are form-decoded
/// (`%XX` and `+`), trimmed, and scheme-gated: `http(s)` URLs must parse and
/// end in a `.dwg`/`.dxf` path segment; any other scheme-like prefix is
/// refused (never dereferenced); anything else is treated as a bare drawing
/// name for the OPFS recent-copy lookup.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn parse_link(search: &str) -> ParsedLink {
let search = search.strip_prefix('?').unwrap_or(search);
// `form_urlencoded` yields `Cow<str>` pairs in order; the FIRST `file`
// param decides — a duplicated one must not turn into two opens.
let value = url::form_urlencoded::parse(search.as_bytes())
.find(|(key, _)| key == "file")
.map(|(_, value)| value.into_owned());
let Some(value) = value else {
return ParsedLink::Absent;
};
let value = value.trim();
if value.is_empty() {
return ParsedLink::Absent;
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let parsed = match url::Url::parse(value) {
Ok(parsed) => parsed,
Err(_) => return ParsedLink::Ignored(LinkRefusal::BadUrl, value.to_string()),
};
return match trailing_drawing_name(&parsed) {
Some(name) => ParsedLink::Target(LinkTarget::Url {
url: parsed.to_string(),
name,
}),
None => ParsedLink::Ignored(LinkRefusal::NotDrawing, value.to_string()),
};
}
if let Some(_scheme) = scheme_prefix(value) {
// Never dereference: the gate the remote-image fetch applies, so a
// `file:`/`javascript:`/drive-letter value dies here, warned only.
return ParsedLink::Ignored(LinkRefusal::Scheme, value.to_string());
}
ParsedLink::Target(LinkTarget::RecentName(value.to_string()))
}
/// The console warning for a refused param (mirrors the drop queues'
/// "ignored …" warnings, which also only reach the console).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn refusal_warning(refusal: LinkRefusal, value: &str) -> String {
match refusal {
LinkRefusal::Scheme => {
format!("ocs: ignored ?file=\"{value}\" (only http(s) URLs are fetched)")
}
LinkRefusal::BadUrl => {
format!("ocs: ignored ?file=\"{value}\" (not a valid absolute URL)")
}
LinkRefusal::NotDrawing => {
format!("ocs: ignored ?file=\"{value}\" (URL must end in a .dwg/.dxf name)")
}
}
}
/// RFC 3986 scheme prefix (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`)
/// of `value`, if any. A single letter before a colon matches (so
/// `C:\drawings\plan.dwg` is refused as a scheme, which is correct on the
/// web: a native path is meaningless there and must not become a fetch).
fn scheme_prefix(value: &str) -> Option<String> {
let bytes = value.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
return None;
}
let end = bytes.iter().position(|&byte| byte == b':')?;
let scheme = &value[..end];
let valid = !scheme.is_empty()
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
valid.then(|| scheme.to_string())
}
/// The URL's trailing path segment, percent-decoded, when it is a
/// `.dwg`/`.dxf` drawing name. Same extension convention as the drop filter
/// (`web_drop::is_drawing_name`): a dot-less segment is its own extension.
fn trailing_drawing_name(url: &url::Url) -> Option<String> {
// `Url::path()` keeps the percent-encoding, so decode it here; query and
// fragment are already excluded by `path()`.
let segment = percent_decode(url.path().rsplit('/').next().unwrap_or_default());
if !is_drawing_name(&segment) {
return None;
}
Some(segment)
}
/// Same convention as `web_drop::is_drawing_name` (which lives in the
/// wasm-gated drop module, so the rule is restated here for the pure parser).
fn is_drawing_name(name: &str) -> bool {
matches!(
name.rsplit('.')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"dwg" | "dxf"
)
}
/// Decode `%XX` escapes (NOT `+`, which path segments do not carry). Invalid
/// or truncated escapes pass through verbatim; undecodable bytes become
/// U+FFFD via lossy conversion — a name is display data, never a path.
fn percent_decode(value: &str) -> String {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
let escape = (bytes[index] == b'%' && index + 2 < bytes.len()).then(|| {
(hex(bytes[index + 1]), hex(bytes[index + 2]))
});
match escape {
Some((Some(high), Some(low))) => {
out.push(high * 16 + low);
index += 3;
}
_ => {
out.push(bytes[index]);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
// ── Web open path ──────────────────────────────────────────────────────────
/// How a deep link settled; the `WebLinkSettled` update arm surfaces it.
/// `pub` to match `WebAutosaveEntry` (public type in a `pub(crate)` module),
/// keeping the `Message` payload free of privacy warnings.
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Clone)]
pub enum LinkOutcome {
/// The bytes entered the guarded drop-open queue — the 50 ms poll opens
/// them exactly like a dropped file.
Queued,
/// Command-line notice (no drawing opened, nothing failed): the named
/// drawing is not in this browser's storage.
Info(String),
/// Command-line error: the fetch was refused or failed.
Error(String),
}
/// Resolve the boot deep link: fetch the URL with the same capped streaming
/// reader the remote-image fetch uses, or read the OPFS recent copy, then
/// queue the bytes for the guarded open poll. Errors render the honest
/// reason (network/CORS, HTTP status, size cap, deadline).
#[cfg(target_arch = "wasm32")]
pub(crate) async fn open_link(target: LinkTarget) -> LinkOutcome {
match target {
LinkTarget::Url { url, name } => {
match crate::scene::model::image_model::fetch_url_bytes(&url).await {
Ok(bytes) => enqueue_or_full(name, bytes),
Err(error) => LinkOutcome::Error(fetch_error_text(&url, error)),
}
}
LinkTarget::RecentName(name) => match super::web_recent::read(&name).await {
Ok(bytes) => enqueue_or_full(name, bytes),
Err(_) => LinkOutcome::Info(format!(
"\"{name}\" is not in this browser's recent drawings; open or drop it once and the link can reopen it."
)),
},
}
}
/// Hand the bytes to the guarded open queue (the same door a drop uses), or
/// report a full queue — the fetch succeeded, so the loss must be visible.
#[cfg(target_arch = "wasm32")]
fn enqueue_or_full(name: String, bytes: Vec<u8>) -> LinkOutcome {
if super::web_drop::enqueue_open(name.clone(), bytes) {
LinkOutcome::Queued
} else {
LinkOutcome::Error(format!(
"Deep-linked drawing \"{name}\" could not be opened: the open queue is full."
))
}
}
/// Plain-English twin of the remote-image failure notes (those strings are
/// built inline in the poll arm and mention images); the classification
/// itself is shared via `RemoteFetchError`.
#[cfg(target_arch = "wasm32")]
fn fetch_error_text(url: &str, error: crate::scene::model::image_model::RemoteFetchError) -> String {
use crate::scene::model::image_model::RemoteFetchError;
match error {
RemoteFetchError::Network => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the network request failed (offline, DNS, or CORS)."
),
RemoteFetchError::Status(status) => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the server returned HTTP {status}."
),
RemoteFetchError::TooLarge => format!(
"Deep-linked drawing \"{url}\" was not opened: it exceeds the 64 MB remote-file limit."
),
RemoteFetchError::Timeout => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the request timed out."
),
}
}
/// Whether `url` shares the page's origin. Unparseable URLs (or a missing
/// window) read as foreign, so the trust notice errs on the loud side.
#[cfg(target_arch = "wasm32")]
pub(crate) fn same_origin(url: &str) -> bool {
let Some(page) = web_sys::window().and_then(|window| window.location().origin().ok()) else {
return false;
};
match url::Url::parse(url) {
// `ascii_serialization` and `location.origin()` agree on omitting
// default ports, so the string comparison is exact.
Ok(parsed) => parsed.origin().ascii_serialization() == page,
Err(_) => false,
}
}
/// The origin to show in the trust notice — the URL's own origin, falling
/// back to the full URL when it cannot be rendered.
#[cfg(target_arch = "wasm32")]
pub(crate) fn display_origin(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.map(|parsed| parsed.origin().ascii_serialization())
.filter(|origin| !origin.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn url_target(search: &str) -> LinkTarget {
match parse_link(search) {
ParsedLink::Target(target) => target,
other => panic!("expected a target, got {other:?}"),
}
}
#[test]
fn absent_when_there_is_no_file_param() {
assert_eq!(parse_link(""), ParsedLink::Absent);
assert_eq!(parse_link("?"), ParsedLink::Absent);
assert_eq!(parse_link("?other=plan.dwg"), ParsedLink::Absent);
assert_eq!(parse_link("?file="), ParsedLink::Absent);
assert_eq!(parse_link("?file=%20"), ParsedLink::Absent);
// Only the exact lowercase key opens; `File=` is not a deep link.
assert_eq!(parse_link("?File=plan.dwg"), ParsedLink::Absent);
// The first occurrence wins — a duplicate must not open two files.
assert_eq!(
parse_link("?file=&file=plan.dwg"),
ParsedLink::Absent,
"an empty first value is absent, not skipped"
);
}
#[test]
fn http_urls_are_targets_with_their_trailing_name() {
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fplans%2Fsite.dwg"),
LinkTarget::Url {
url: "https://example.com/plans/site.dwg".to_string(),
name: "site.dwg".to_string()
}
);
// Unencoded URL in the query string works too (senders should
// encode, but browsers hand the raw text through).
assert_eq!(
url_target("?file=http://example.com/a.DXF"),
LinkTarget::Url {
url: "http://example.com/a.DXF".to_string(),
name: "a.DXF".to_string()
}
);
// Scheme check is case-insensitive; Url normalizes to lowercase.
assert_eq!(
url_target("?file=HTTPS://example.com/x.dwg"),
LinkTarget::Url {
url: "https://example.com/x.dwg".to_string(),
name: "x.dwg".to_string()
}
);
// Query and fragment never leak into the extracted name.
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fp.dwg%3Fv%3D1%23frag"),
LinkTarget::Url {
url: "https://example.com/p.dwg?v=1#frag".to_string(),
name: "p.dwg".to_string()
}
);
// Percent-encoded path segments decode into the tab name.
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fmy%2520plan.dwg"),
LinkTarget::Url {
// The param decodes to ".../my%20plan.dwg", which the URL
// keeps encoded in its path (a raw space is invalid there).
url: "https://example.com/my%20plan.dwg".to_string(),
name: "my plan.dwg".to_string()
}
);
}
#[test]
fn non_http_schemes_are_refused_not_fetched() {
for bad in [
"file:///C:/plan.dwg",
"javascript:alert(1)",
"data:text/plain,hello",
"ftp://example.com/plan.dwg",
"blob:https://example.com/uuid",
// A drive-letter colon is a valid single-letter scheme; a native
// path is meaningless on the web and must not become a fetch.
"C:\\drawings\\plan.dwg",
] {
assert_eq!(
parse_link(&format!("?file={bad}")),
ParsedLink::Ignored(LinkRefusal::Scheme, bad.to_string()),
"{bad} must be refused as a scheme"
);
}
// The refusal names the reason and echoes the value.
assert_eq!(
refusal_warning(LinkRefusal::Scheme, "javascript:alert(1)"),
"ocs: ignored ?file=\"javascript:alert(1)\" (only http(s) URLs are fetched)"
);
}
#[test]
fn http_urls_must_end_in_a_drawing_name() {
// No trailing segment, a directory URL, or a foreign extension.
for search in [
"?file=https%3A%2F%2Fexample.com%2F",
"?file=https%3A%2F%2Fexample.com%2Fplans%2F",
"?file=https%3A%2F%2Fexample.com%2Fplan.pdf",
"?file=https%3A%2F%2Fexample.com%2Fplan.dwg.txt",
// The extension check sees through dot-doubling tricks.
"?file=https%3A%2F%2Fexample.com%2F..dwgx",
] {
assert!(
matches!(parse_link(search), ParsedLink::Ignored(LinkRefusal::NotDrawing, _)),
"{search} must be refused as not a drawing"
);
}
// A dot-less segment is its own extension, mirroring the drop filter.
assert!(matches!(
url_target("?file=https%3A%2F%2Fexample.com%2FDWG"),
LinkTarget::Url { name, .. } if name == "DWG"
));
}
#[test]
fn malformed_http_urls_are_refused() {
assert!(matches!(
parse_link("?file=http%3A%2F%2F"),
ParsedLink::Ignored(LinkRefusal::BadUrl, _)
));
}
#[test]
fn bare_names_target_the_recent_store() {
assert_eq!(
url_target("?file=plan.dwg"),
LinkTarget::RecentName("plan.dwg".to_string())
);
// Encoded spaces and non-ASCII decode into the name.
assert_eq!(
url_target("?file=my+plan.dxf"),
LinkTarget::RecentName("my plan.dxf".to_string())
);
assert_eq!(
url_target("?file=%C3%A7izim.dwg"),
LinkTarget::RecentName("çizim.dwg".to_string())
);
// No scheme and no colon, but slashes stay a name — the recent store
// keys by the hashed name, so there is nothing to traverse.
assert_eq!(
url_target("?file=sub%2Fplan.dwg"),
LinkTarget::RecentName("sub/plan.dwg".to_string())
);
}
#[test]
fn percent_decode_only_touches_valid_escapes() {
assert_eq!(percent_decode("a%20b"), "a b");
assert_eq!(percent_decode("%2Fd%2E"), "/d.");
// Truncated and invalid escapes pass through verbatim.
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
assert_eq!(percent_decode("%2"), "%2");
// Non-ASCII text survives untouched; bad UTF-8 escapes go lossy.
assert_eq!(percent_decode("çizim.dwg"), "çizim.dwg");
assert_eq!(percent_decode("%ff"), "\u{fffd}");
}
#[test]
fn scheme_prefix_matches_rfc3986_shape_only() {
assert_eq!(scheme_prefix("https://x"), Some("https".to_string()));
assert_eq!(scheme_prefix("c:\\x"), Some("c".to_string()));
assert_eq!(
scheme_prefix("x1+2-3.4:rest"),
Some("x1+2-3.4".to_string())
);
// A colon after a non-scheme character (space, slash, digit start)
// is not a scheme.
assert_eq!(scheme_prefix("plan.dwg"), None);
assert_eq!(scheme_prefix("a b:c"), None);
assert_eq!(scheme_prefix("1http://x"), None);
assert_eq!(scheme_prefix("no-colon"), None);
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Web session memory: which drawings were open (and which was active) the
//! last time a save/open/close touched the tab list.
//!
//! Deliberately NOT a session-restore engine: nothing ever auto-opens from
//! this list. The next boot's crash-recovery flow reads it only to show what
//! the session had and — once restores complete — to put the focus back on
//! the tab the session remembered as active. A small `Vec<String>` + index
//! in localStorage, rewritten in full on every save/open/close, is the whole
//! cost.
use serde::{Deserialize, Serialize};
/// localStorage key, next to `opencadstudio.settings`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) const SESSION_KEY: &str = "opencadstudio.session";
/// The remembered tab list. `active` indexes `tabs`; construction and
/// parsing both clamp it so a stale index can never point outside the list.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WebSession {
pub tabs: Vec<String>,
pub active: usize,
}
impl WebSession {
pub fn new(tabs: Vec<String>, active: usize) -> Self {
Self {
active: active.min(tabs.len().saturating_sub(1)),
tabs,
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
/// Forgiving parse: a missing or out-of-range `active` clamps instead of
/// failing (the list is advisory memory, never load-bearing).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
fn from_json(json: &str) -> Option<Self> {
let session: Self = serde_json::from_str(json).ok()?;
Some(Self::new(session.tabs, session.active))
}
}
/// Remember `tabs`/`active` for the next boot. Best-effort: unavailable or
/// read-only storage silently skips the write.
#[cfg(target_arch = "wasm32")]
pub fn persist(tabs: &[String], active: usize) {
if cfg!(test) {
return;
}
let session = WebSession::new(tabs.to_vec(), active);
if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten())
{
let _ = storage.set_item(SESSION_KEY, &session.to_json());
}
}
/// What the previous session had open, if it ever recorded anything.
#[cfg(target_arch = "wasm32")]
pub fn load() -> Option<WebSession> {
let json = web_sys::window()
.and_then(|window| window.local_storage().ok().flatten())
.and_then(|storage| storage.get_item(SESSION_KEY).ok().flatten())?;
WebSession::from_json(&json)
}
// Pure logic only — the localStorage half needs a browser and is exercised
// by hand (and by every web boot's restore flow).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_session_round_trips_through_json() {
let session = WebSession::new(
vec!["plan.dwg".to_string(), "sketch.dxf".to_string()],
1,
);
let restored = WebSession::from_json(&session.to_json()).expect("must parse");
assert_eq!(restored, session);
assert_eq!(restored.tabs, ["plan.dwg", "sketch.dxf"]);
assert_eq!(restored.active, 1);
}
#[test]
fn an_out_of_range_active_index_clamps_instead_of_failing() {
// Construction clamps…
assert_eq!(WebSession::new(vec!["a.dwg".to_string()], 9).active, 0);
// …and so does a parse of stale or hostile JSON, both directions.
let over = WebSession::from_json(r#"{"tabs":["a.dwg","b.dwg"],"active":99}"#)
.expect("must parse");
assert_eq!(over.active, 1);
let under = WebSession::from_json(r#"{"tabs":["a.dwg"],"active":0}"#).unwrap();
assert_eq!(under.active, 0);
}
#[test]
fn garbage_is_rejected_and_an_empty_list_is_fine() {
assert!(WebSession::from_json("").is_none());
assert!(WebSession::from_json("not json").is_none());
assert!(WebSession::from_json("{}").is_none()); // missing `tabs` field
// A session whose every tab closed is still valid memory (it says
// the crash lost nothing that was visibly open).
let empty = WebSession::from_json(r#"{"tabs":[],"active":0}"#).unwrap();
assert!(empty.tabs.is_empty());
assert_eq!(empty.active, 0);
}
#[test]
fn duplicate_names_are_kept_as_is() {
// Two tabs may share a display name on the web; the memory list
// records the list as it was, de-duplication is not its job.
let session = WebSession::new(
vec!["a.dwg".to_string(), "a.dwg".to_string()],
1,
);
assert_eq!(
WebSession::from_json(&session.to_json()).unwrap(),
session
);
}
}
+3
View File
@@ -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")),
+7 -1
View File
@@ -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 {
+16
View File
@@ -45,6 +45,7 @@
<link data-trunk rel="copy-file" href="web/videos.json" />
<link data-trunk rel="copy-file" href="web/locale-labels.json" />
<link data-trunk rel="copy-file" href="web/ocs-control.js" />
<link data-trunk rel="copy-file" href="web/sw.js" />
</head>
<body>
<div id="loading">
@@ -53,6 +54,21 @@
<div class="sub">Loading…</div>
</div>
<script src="ocs-control.js" defer></script>
<script>
// Offline support: register the service worker. It is copied next to
// this page by Trunk (see the copy-file link in <head>), so the
// relative URL resolves to /app/sw.js and the registration scope is
// /app/ — the whole app. Registered immediately (not on load) so the
// worker starts capturing this visit's asset requests as early as
// possible. sw.js never reloads the page on update: a new worker
// waits until every tab on the old version has closed.
(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("sw.js").catch(error => {
console.warn("service worker registration failed; offline support disabled:", error);
});
})();
</script>
<script>
(() => {
const splash = document.getElementById("loading");
+136
View File
@@ -0,0 +1,136 @@
// Open CAD Studio web app service worker: offline app-shell caching.
//
// Trunk copies this file to dist/app/sw.js (copy-file link in web-app.html),
// so it is served at /app/sw.js and its scope is /app/ — the whole app. The
// page registers it with a relative URL; see the inline script in
// web-app.html.
//
// Caching strategy
// - Navigations (any path under /app/, deep links included): network-first.
// A successful response is cached under the canonical shell URL (the
// scope root) and returned, so updates land on the next visit; offline
// the cached shell is served instead, which also serves as the fallback
// for deep-linked paths.
// - Other same-origin GET requests (wasm, js, workers, fonts, JSON):
// cache-first with a background revalidate. Trunk content-hashes the
// heavy assets (OpenCADStudio-<hash>[_bg].{js,wasm}), so a cached copy
// can never be stale and revalidation is effectively free; non-hashed
// files (fonts/*.ttf, *.json, ocs-*.js) are silently refreshed in the
// background. Everything the app loads is captured during the first
// online visit, after which the ~55 MB bundle is served from the cache
// instead of re-downloaded on every visit.
// - Non-GET and cross-origin requests: passthrough (never intercepted).
//
// Versioning
// CACHE_VERSION below is a placeholder ("v1") so this file works verbatim
// under `trunk serve`. The Pages workflow stamps it with a hash of the
// finished bundle after every build, so each deploy ships a fresh cache
// name and the activate step below deletes the previous deploy's cache.
//
// Update flow
// A changed sw.js installs a new worker, but this code deliberately never
// calls skipWaiting() and never reloads the page: the browser keeps the
// new worker in the "waiting" state until every tab running the old
// version has closed, so an in-progress editing session is never disrupted
// mid-session. The new worker (and its new cache) takes over on the first
// visit after that. Nothing is shown to the user — the browser chrome
// already handles the "update available" UX.
const CACHE_VERSION = "v1"; // stamped by the Pages workflow (bundle hash)
const CACHE_NAME = `ocs-app-${CACHE_VERSION}`;
const SHELL_URL = new URL("./", self.registration.scope);
self.addEventListener("install", (event) => {
// Precache the app shell (index.html) so offline navigation works from the
// first visit after registration. If the shell cannot be fetched the
// install fails and the browser retries on a later navigation — a broken
// shell must never be cached.
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll([SHELL_URL.href])),
);
});
self.addEventListener("activate", (event) => {
// Delete every cache from previous versions, then start controlling pages
// that loaded before this worker was ready, so the current visit's requests
// are captured without needing a reload.
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name)),
);
await self.clients.claim();
})(),
);
});
// Cache a copy of a response unless it is not a complete success. Quota or
// write errors are swallowed: caching must never break the app itself.
const put = async (cache, key, response) => {
if (!response || !response.ok) return;
try {
await cache.put(key, response.clone());
} catch {}
};
self.addEventListener("fetch", (event) => {
const request = event.request;
// Only same-origin GETs are ours to serve.
if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return;
// Range requests (media seeking) cannot be served from a full-body cache.
if (request.headers.has("range")) return;
if (request.mode === "navigate") {
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
try {
const fresh = await fetch(request);
await put(cache, SHELL_URL, fresh);
return fresh;
} catch {
const cached = await cache.match(SHELL_URL);
if (cached) return cached;
return new Response("Open CAD Studio is offline.", {
status: 503,
headers: {"Content-Type": "text/plain; charset=utf-8"},
});
}
})(),
);
return;
}
// Cache-first with background revalidate. On a hit the cached copy is
// returned immediately and a fresh one is fetched (and cached) in the
// background; on a miss the network response is cached before being
// returned, making the next visit offline-capable.
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const network = fetch(request)
.then(async (fresh) => {
await put(cache, request, fresh);
return fresh;
})
.catch(() => undefined);
if (cached) {
// Keep the worker alive until the background revalidate finishes.
event.waitUntil(network);
return cached;
}
const fresh = await network;
if (fresh) return fresh;
return new Response("Open CAD Studio is offline.", {
status: 503,
headers: {"Content-Type": "text/plain; charset=utf-8"},
});
})(),
);
});