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

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

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

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

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

No files matched your search

+3
View File
@@ -164,6 +164,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"] }
+4
View File
@@ -89,6 +89,8 @@ The project is under active development. Keep backups of important production dr
Use the [web app](https://www.opencadstudio.com) for immediate access with no installation. Drawings are selected through the browser and saved as local downloads.
The web app also accepts a `?file=` URL parameter, so a shared or embedded link can open a drawing directly: `?file=<url-encoded https://example.com/plan.dwg>` fetches and opens that drawing (only `http(s)` URLs ending in a `.dwg`/`.dxf` name), while `?file=plan.dwg` reopens a drawing previously opened in the same browser. Opening from another origin is announced on the command line before the fetch starts.
Use the desktop application for native file associations, file-manager thumbnails, system printing, PDF output, external plugins, command scripts, and headless automation. Release builds are available for Windows, Linux, and Apple Silicon macOS.
## Install
@@ -181,6 +183,8 @@ never go missing from the bundle):
sh scripts/build-web.sh
```
The bundle ships a service worker (`web/sw.js`, deployed at `/app/sw.js`) that caches the app shell: after one online visit the app starts fully offline and the ~55 MB wasm bundle is served from the browser cache instead of re-downloaded. `build-web.sh` stamps a hash of the finished bundle into `sw.js` as the cache version, so every deploy ships a fresh cache and stale ones are cleaned up automatically.
## Automation
The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications:
+4 -1
View File
@@ -2010,6 +2010,9 @@ file =
.drawing-not-loaded-in-the-browser = رسم غير محمّل في المتصفح: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" بأبعاد __ocs_fmt_1__×__ocs_fmt_2__ بكسل ويتطلب فك ترميزه __ocs_fmt_3__ ميغابايت، وهو فوق حد __ocs_fmt_4__ ميغابايت للصور في هذا الإصدار — لا يمكن إرفاقه. تُرفَق الصور ضمن الحد تلقائيًا بدقة عرض مخفَّضة عند الحاجة.
.image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه.
.restore-all = استعادة الكل
.restoring-drawing-s = جارٍ استعادة __ocs_fmt_0__ رسم…
.session-had-open-drawing-s = كانت الجلسة تضم __ocs_fmt_0__ رسمًا مفتوحًا.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ حدد الكائنات (__ocs_arg_count__ محدد، Enter للتطبيق):
@@ -3590,8 +3593,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: مقياس التعليق التوضيحي النشط غير متاح.
.opening-ocs-discussions-for-help-and-questions = يجري فتح مناقشات OCS للمساعدة والأسئلة...
.opening-ocs-web = يجري فتح OCS Web...
.opening-the-command-reference = فتح مرجع الأوامر...
.browser-cannot-open-typed-path = لا يستطيع المتصفح فتح مسار مكتوب؛ اختر الملف بدلاً من ذلك.
.opening-the-command-reference = فتح مرجع الأوامر...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود.
.opening-patreon-page = يجري فتح صفحة Patreon...
.opening-feedback-page = يجري فتح صفحة الملاحظات...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Чертежът не е зареден в браузъра: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" е __ocs_fmt_1__×__ocs_fmt_2__ px и при декодиране би заемала __ocs_fmt_3__ MB — над __ocs_fmt_4__ MB лимита за изображения на тази версия, затова не може да бъде прикачена. Изображенията в рамките на лимита се прикачват автоматично с намалена разделителна способност при нужда.
.image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи.
.restore-all = Възстанови всички
.restoring-drawing-s = Възстановяване на __ocs_fmt_0__ чертеж(а)…
.session-had-open-drawing-s = Сесията е имала __ocs_fmt_0__ отворени чертежа.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Избери обекти (__ocs_arg_count__ избрани, Enter за прилагане):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: активният анотационен мащаб не е наличен.
.opening-ocs-discussions-for-help-and-questions = Отваряне на OCS дискусии за помощ и въпроси...
.opening-ocs-web = Отваряне на OCS уебсайта...
.opening-the-command-reference = Отваряне на справочника за команди...
.browser-cannot-open-typed-path = Браузърът не може да отвори въведен път; изберете файла.
.opening-the-command-reference = Отваряне на справочника за команди...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен.
.opening-patreon-page = Отваряне на страницата в Patreon...
.opening-feedback-page = Отваряне на страницата за обратна връзка...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Výkres není v prohlížeči načten: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" má __ocs_fmt_1__×__ocs_fmt_2__ px a při dekódování by zabrala __ocs_fmt_3__ MB, přes limit __ocs_fmt_4__ MB pro obrázky v této verzi — nelze ji připojit. Obrázky v rámci limitu se podle potřeby připojují automaticky se sníženým rozlišením zobrazení.
.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.
.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(ů).
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Vyberte objekty (vybráno __ocs_arg_count__, použít Enter):
@@ -3575,8 +3578,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktivní měřítko anotací není k dispozici.
.opening-ocs-discussions-for-help-and-questions = Otevírání diskuzí OCS pro nápovědu a dotazy...
.opening-ocs-web = Otevírání webu OCS...
.opening-the-command-reference = Otevírání referenční příručky příkazů...
.browser-cannot-open-typed-path = Prohlížeč nemůže otevřít zadanou cestu; vyberte soubor.
.opening-the-command-reference = Otevírání referenční příručky příkazů...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován.
.opening-patreon-page = Otevírání stránky Patreon...
.opening-feedback-page = Otevírá se stránka zpětné vazby...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Zeichnung ist nicht im Browser geladen: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" hat __ocs_fmt_1__×__ocs_fmt_2__ px und würde beim Dekodieren __ocs_fmt_3__ MB belegen, über dem __ocs_fmt_4__ MB Bildlimit dieses Builds — es kann nicht angehängt werden. Bilder innerhalb des Limits werden bei Bedarf automatisch mit reduzierter Anzeigeanflösung angehängt.
.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.
.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).
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objekte auswählen (__ocs_arg_count__ ausgewählt, Eingabe zum Anwenden):
@@ -3576,8 +3579,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: Die aktive Annotationsskala ist nicht verfügbar.
.opening-ocs-discussions-for-help-and-questions = Eröffnung OCS Diskussionen für Hilfe und Fragen...
.opening-ocs-web = Öffnen OCS Web...
.opening-the-command-reference = Öffnen der Befehlsreferenz...
.browser-cannot-open-typed-path = Der Browser kann einen eingegebenen Pfad nicht öffnen; wähle die Datei.
.opening-the-command-reference = Öffnen der Befehlsreferenz...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten.
.opening-patreon-page = Eröffnungsseite Patreon...
.opening-feedback-page = Feedbackseite öffnen...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Το σχέδιο δεν έχει φορτωθεί στο πρόγραμμα περιήγησης: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = Το "__ocs_fmt_0__" είναι __ocs_fmt_1__×__ocs_fmt_2__ px και η αποκωδικοποίησή του θα απαιτούσε __ocs_fmt_3__ MB, πάνω από το όριο __ocs_fmt_4__ MB για εικόνες σε αυτή την έκδοση — δεν μπορεί να επισυναφθεί. Οι εικόνες εντός του ορίου επισυνάπτονται κατά ανάγκη αυτόματα σε μειωμένη ανάλυση εμφάνισης.
.image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί.
.restore-all = Επαναφορά όλων
.restoring-drawing-s = Επαναφορά __ocs_fmt_0__ σχέδιο(α)…
.session-had-open-drawing-s = Η συνεδρία είχε __ocs_fmt_0__ ανοιχτά σχέδια.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Επιλέξτε αντικείμενα (__ocs_arg_count__ επιλεγμένα, Enter για εφαρμογή):
@@ -3572,8 +3575,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: η ενεργή κλίμακα επισημείωσης δεν είναι διαθέσιμη.
.opening-ocs-discussions-for-help-and-questions = Άνοιγμα των συζητήσεων OCS για βοήθεια και ερωτήσεις...
.opening-ocs-web = Άνοιγμα του OCS Web...
.opening-the-command-reference = Άνοιγμα της αναφοράς εντολών...
.browser-cannot-open-typed-path = Ο browser δεν μπορεί να ανοίξει πληκτρολογημένη διαδρομή· διάλεξε το αρχείο.
.opening-the-command-reference = Άνοιγμα της αναφοράς εντολών...
.already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο.
.opening-patreon-page = Άνοιγμα της σελίδας Patreon...
.opening-feedback-page = Άνοιγμα της σελίδας σχολίων...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Drawing not loaded in the browser: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" is __ocs_fmt_1__×__ocs_fmt_2__ px and would decode to __ocs_fmt_3__ MB, over the __ocs_fmt_4__ MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed.
.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.
.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).
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Select objects (__ocs_arg_count__ selected, Enter to apply):
@@ -3573,8 +3576,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: the active annotation scale is unavailable.
.opening-ocs-discussions-for-help-and-questions = Opening OCS Discussions for help and questions...
.opening-ocs-web = Opening OCS Web...
.opening-the-command-reference = Opening the command reference...
.browser-cannot-open-typed-path = The browser cannot open a typed path; pick the file instead.
.opening-the-command-reference = Opening the command reference...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept.
.opening-patreon-page = Opening Patreon page...
.opening-feedback-page = Opening feedback page...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Dibujo no cargado en el navegador: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" tiene __ocs_fmt_1__×__ocs_fmt_2__ px y al decodificarse ocuparía __ocs_fmt_3__ MB, por encima del límite de __ocs_fmt_4__ MB por imagen de esta versión; no se puede adjuntar. Las imágenes dentro del límite se adjuntan automáticamente con una resolución de visualización reducida cuando es necesario.
.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.
.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.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleccione objetos (__ocs_arg_count__ seleccionados, Enter para aplicar):
@@ -3578,8 +3581,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: la escala de anotación activa no está disponible.
.opening-ocs-discussions-for-help-and-questions = Abriendo Debates de OCS para obtener ayuda y hacer preguntas...
.opening-ocs-web = Abriendo OCS Web...
.opening-the-command-reference = Abriendo la referencia de comandos...
.browser-cannot-open-typed-path = El navegador no puede abrir una ruta escrita; elige el archivo.
.opening-the-command-reference = Abriendo la referencia de comandos...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente.
.opening-patreon-page = Abriendo la página de Patreon...
.opening-feedback-page = Abriendo la página de comentarios...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Piirustusta ei ole ladattu selaimeen: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" on __ocs_fmt_1__×__ocs_fmt_2__ px ja sen purkaminen veisi __ocs_fmt_3__ MB, yli tämän version __ocs_fmt_4__ MB:n kuvarajan — sitä ei voi liittää. Rajan sisällä olevat kuvat liitetään tarvittaessa automaattisesti pienemmällä näyttötarkkuudella.
.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ää.
.restore-all = Palauta kaikki
.restoring-drawing-s = Palautetaan __ocs_fmt_0__ piirustusta…
.session-had-open-drawing-s = Istunnossa oli __ocs_fmt_0__ avointa piirustusta.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Valitse objektit (__ocs_arg_count__ valittu, Enter käytettäväksi):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktiivinen huomautusasteikko ei ole käytettävissä.
.opening-ocs-discussions-for-help-and-questions = OCS-keskustelujen avaaminen apua ja kysymyksiä varten...
.opening-ocs-web = Avataan OCS Web...
.opening-the-command-reference = Avataan komentoviittausta...
.browser-cannot-open-typed-path = Selain ei voi avata kirjoitettua polkua; valitse tiedosto.
.opening-the-command-reference = Avataan komentoviittausta...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin.
.opening-patreon-page = Avataan Patreon-sivua...
.opening-feedback-page = Palautesivua avataan...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Dessin non chargé dans le navigateur : __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" fait __ocs_fmt_1__×__ocs_fmt_2__ px et son décodage nécessiterait __ocs_fmt_3__ Mo, au-delà de la limite de __ocs_fmt_4__ Mo par image de cette version — elle ne peut pas être jointe. Les images dans la limite sont jointes automatiquement à une résolution d'affichage réduite si nécessaire.
.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.
.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).
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Sélectionner les objets (__ocs_arg_count__ sélectionnés, Entrez pour appliquer) :
@@ -3578,8 +3581,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE : l'échelle d'annotation active n'est pas disponible.
.opening-ocs-discussions-for-help-and-questions = Ouverture OCS Discussions pour l'aide et les questions...
.opening-ocs-web = Ouverture du site Web OCS...
.opening-the-command-reference = Ouverture de la référence des commandes...
.browser-cannot-open-typed-path = Le navigateur ne peut pas ouvrir un chemin saisi ; choisissez le fichier.
.opening-the-command-reference = Ouverture de la référence des commandes...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé.
.opening-patreon-page = Ouverture de la page Patreon...
.opening-feedback-page = Ouverture de la page de rétroaction...
+4 -1
View File
@@ -1986,6 +1986,9 @@ file =
.drawing-not-loaded-in-the-browser = ड्रॉइंग ब्राउज़र में लोड नहीं है: __ocs_fmt_0__।
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px है और इसे डिकोड करने पर __ocs_fmt_3__ MB लगेगा, जो इस बिल्ड की __ocs_fmt_4__ MB छवि सीमा से अधिक है — इसे संलग्न नहीं किया जा सकता। सीमा के भीतर की छवियाँ आवश्यकता पड़ने पर स्वचालित रूप से घटी हुई डिस्प्ले रिज़ॉल्यूशन पर संलग्न की जाती हैं।
.image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता।
.restore-all = सभी पुनर्स्थापित करें
.restoring-drawing-s = __ocs_fmt_0__ ड्रॉइंग पुनर्स्थापित की जा रही हैं…
.session-had-open-drawing-s = सत्र में __ocs_fmt_0__ ड्रॉइंग खुली थीं।
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ ऑब्जेक्ट चुनें (__ocs_arg_count__ चयनित, लागू करने के लिए Enter दबाएँ):
@@ -3566,8 +3569,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: सक्रिय एनोटेशन स्केल अनुपलब्ध है।
.opening-ocs-discussions-for-help-and-questions = OCS मदद और प्रश्नों के लिए चर्चा खोलना।
.opening-ocs-web = OCS वेब खोलना ...
.opening-the-command-reference = कमांड संदर्भ खोला जा रहा है...
.browser-cannot-open-typed-path = ब्राउज़र टाइप किए गए पथ को नहीं खोल सकता; इसके बजाय फ़ाइल चुनें।
.opening-the-command-reference = कमांड संदर्भ खोला जा रहा है...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई।
.opening-patreon-page = Patreon पृष्ठ खोला जा रहा है…
.opening-feedback-page = प्रतिक्रिया पृष्ठ खोलना
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = A raj nincs betöltve a böngészőben: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = A(z) "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px felbontású, és a dekódolása __ocs_fmt_3__ MB-ot igényelne — ez meghaladja e build __ocs_fmt_4__ MB-os képhatárát, ezért nem csatolható. A határon belüli képek szükség esetén automatikusan csökkentett megjelenítési felbontással csatolódnak.
.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ó.
.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.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objektumok kiválasztása (__ocs_arg_count__ kiválasztva, Enter alkalmazni):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: az aktív annotációs skála nem érhető el.
.opening-ocs-discussions-for-help-and-questions = A OCS beszélgetések megnyitása segítségért és kérdésekért...
.opening-ocs-web = A OCS web megnyitása...
.opening-the-command-reference = A parancsreferencia megnyitása...
.browser-cannot-open-typed-path = A böngésző nem nyithat meg beírt útvonalat; válaszd a fájlt.
.opening-the-command-reference = A parancsreferencia megnyitása...
.already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve.
.opening-patreon-page = Patreon oldal megnyitása...
.opening-feedback-page = Visszajelzési oldal megnyitása...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Disegno non caricato nel browser: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" è di __ocs_fmt_1__×__ocs_fmt_2__ px e la decodifica richiederebbe __ocs_fmt_3__ MB, oltre il limite di __ocs_fmt_4__ MB per immagine di questa build: non può essere allegata. Le immagini entro il limite vengono allegate automaticamente a risoluzione di visualizzazione ridotta quando necessario.
.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.
.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.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleziona oggetti (__ocs_arg_count__ selezionato, Enter da applicare):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: la scala di annotazione attiva non è disponibile.
.opening-ocs-discussions-for-help-and-questions = Apertura delle discussioni OCS per assistenza e domande...
.opening-ocs-web = Apertura del Web OCS...
.opening-the-command-reference = Apertura del riferimento dei comandi...
.browser-cannot-open-typed-path = Il browser non può aprire un percorso digitato; scegli il file.
.opening-the-command-reference = Apertura del riferimento dei comandi...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto.
.opening-patreon-page = Apertura della pagina Patreon...
.opening-feedback-page = Apertura della pagina di feedback...
+4 -1
View File
@@ -1988,6 +1988,9 @@ file =
.drawing-not-loaded-in-the-browser = 図面がブラウザーに読み込まれていません: __ocs_fmt_0__。
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" は __ocs_fmt_1__×__ocs_fmt_2__ px で、デコードすると __ocs_fmt_3__ MB 必要となり、このビルドの __ocs_fmt_4__ MB の画像上限を超えるため添付できません。上限内の画像は、必要に応じて自動的に低下させた表示解像度で添付されます。
.image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。
.restore-all = すべて復元
.restoring-drawing-s = __ocs_fmt_0__ 件の図面を復元しています…
.session-had-open-drawing-s = セッションでは __ocs_fmt_0__ 件の図面が開いていました。
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ オブジェクトを選択(__ocs_arg_count__ 個選択、Enter で適用):
@@ -3566,8 +3569,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 現在の注釈尺度は利用できません。
.opening-ocs-discussions-for-help-and-questions = ヘルプと質問のため OCS ディスカッションを開いています...
.opening-ocs-web = OCS Web を開いています...
.opening-the-command-reference = コマンドリファレンスを開いています...
.browser-cannot-open-typed-path = ブラウザは入力されたパスを開けません。ファイルを選択してください。
.opening-the-command-reference = コマンドリファレンスを開いています...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。
.opening-patreon-page = Patreon ページを開いています...
.opening-feedback-page = フィードバックページを開いています...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = 도면이 브라우저에 로드되지 않았습니다: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"은(는) __ocs_fmt_1__×__ocs_fmt_2__ px이며 디코딩 시 __ocs_fmt_3__ MB가 필요해 이 빌드의 __ocs_fmt_4__ MB 이미지 한도를 초과하므로 첨부할 수 없습니다. 한도 이내의 이미지는 필요할 때 자동으로 낮춘 표시 해상도로 첨부됩니다.
.image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다.
.restore-all = 모두 복원
.restoring-drawing-s = __ocs_fmt_0__ 도면을 복원하는 중…
.session-had-open-drawing-s = 세션에서 __ocs_fmt_0__ 도면이 열려 있었습니다.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 객체 선택(__ocs_arg_count__ 선택, Enter 적용):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 활성 주석 축척을 사용할 수 없습니다.
.opening-ocs-discussions-for-help-and-questions = OCS 열기 도움말 및 질문에 대한 토론...
.opening-ocs-web = OCS 웹을 여는 중...
.opening-the-command-reference = 명령 참조 열기...
.browser-cannot-open-typed-path = 브라우저는 입력한 경로를 열 수 없습니다. 파일을 선택하세요.
.opening-the-command-reference = 명령 참조 열기...
.already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다.
.opening-patreon-page = Patreon 페이지를 여는 중...
.opening-feedback-page = 피드백 페이지를 여는 중...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Tekening is niet geladen in de browser: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" is __ocs_fmt_1__×__ocs_fmt_2__ px en zou bij het decoderen __ocs_fmt_3__ MB nodig hebben, boven de __ocs_fmt_4__ MB afbeeldingslimiet van deze build — het kan niet worden bijgevoegd. Afbeeldingen binnen de limiet worden zo nodig automatisch met een verlaagde weergaveresolutie bijgevoegd.
.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.
.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.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecteer objecten (__ocs_arg_count__ geselecteerd, Voer in om toe te passen):
@@ -3576,8 +3579,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: de actieve annotatieschaal is niet beschikbaar.
.opening-ocs-discussions-for-help-and-questions = Openen OCS Discussie voor hulp en vragen...
.opening-ocs-web = Openen OCS Web...
.opening-the-command-reference = Commandoreferentie openen...
.browser-cannot-open-typed-path = De browser kan een getypt pad niet openen; kies het bestand.
.opening-the-command-reference = Commandoreferentie openen...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard.
.opening-patreon-page = Patreon pagina openen...
.opening-feedback-page = Herinneringspagina openen...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Rysunek nie został załadowany w przeglądarce: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" ma __ocs_fmt_1__×__ocs_fmt_2__ px, a jej dekodowanie wymagałoby __ocs_fmt_3__ MB — ponad __ocs_fmt_4__ MB limit obrazu w tej wersji — więc nie może być dołączona. Obrazy w ramach limitu są w razie potrzeby dołączane automatycznie w obniżonej rozdzielczości wyświetlania.
.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ć.
.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.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Wybierz obiekty (wybrano __ocs_arg_count__, zastosowano Enter):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktywna skala adnotacji jest niedostępna.
.opening-ocs-discussions-for-help-and-questions = Otwieranie dyskusji OCS w celu uzyskania pomocy i pytań...
.opening-ocs-web = Otwieranie OCS Web...
.opening-the-command-reference = Otwieranie dokumentacji poleceń...
.browser-cannot-open-typed-path = Przeglądarka nie otworzy wpisanej ścieżki; wybierz plik.
.opening-the-command-reference = Otwieranie dokumentacji poleceń...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik.
.opening-patreon-page = Otwieranie strony Patreon...
.opening-feedback-page = Otwieram stronę z opiniami...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Desenho não carregado no navegador: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" tem __ocs_fmt_1__×__ocs_fmt_2__ px e a decodificação exigiria __ocs_fmt_3__ MB, acima do limite de __ocs_fmt_4__ MB por imagem desta versão — não é possível anexá-la. Imagens dentro do limite são anexadas automaticamente com resolução de exibição reduzida quando necessário.
.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.
.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.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecione objetos (__ocs_arg_count__ selecionados, Enter para aplicar):
@@ -3578,8 +3581,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: a escala de anotação ativa não está disponível.
.opening-ocs-discussions-for-help-and-questions = Abrindo Discussões do OCS para ajuda e perguntas...
.opening-ocs-web = Abrindo OCS Web...
.opening-the-command-reference = Abrindo a referência de comandos...
.browser-cannot-open-typed-path = O navegador não pode abrir um caminho digitado; escolha o ficheiro.
.opening-the-command-reference = Abrindo a referência de comandos...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido.
.opening-patreon-page = Abrindo página do Patreon...
.opening-feedback-page = Abrindo página de comentários...
+4 -1
View File
@@ -2001,6 +2001,9 @@ file =
.drawing-not-loaded-in-the-browser = Чертёж не загружен в браузере: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" — __ocs_fmt_1__×__ocs_fmt_2__ px; для декодирования потребовалось бы __ocs_fmt_3__ МБ, что превышает ограничение __ocs_fmt_4__ МБ на изображение в этой сборке, поэтому вложить его нельзя. Изображения в пределах ограничения при необходимости вкладываются автоматически с пониженным разрешением отображения.
.image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя.
.restore-all = Восстановить все
.restoring-drawing-s = Восстановление __ocs_fmt_0__ чертежа(ей)…
.session-had-open-drawing-s = В сессии было открыто __ocs_fmt_0__ чертежа(ей).
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Выберите объекты (__ocs_arg_count__ Выберите, Введите для применения):
@@ -3579,8 +3582,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: активный масштаб аннотаций недоступен.
.opening-ocs-discussions-for-help-and-questions = Открытие OCS Дискуссии о помощи и вопросах
.opening-ocs-web = Открытие OCS Веб-сайт
.opening-the-command-reference = Открытие справочника команд...
.browser-cannot-open-typed-path = Браузер не может открыть введённый путь; выберите файл.
.opening-the-command-reference = Открытие справочника команд...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён.
.opening-patreon-page = Открытие страницы Patreon...
.opening-feedback-page = Открываем страницу обратной связи...
+4 -1
View File
@@ -1981,6 +1981,9 @@ file =
.drawing-not-loaded-in-the-browser = Çizim tarayıcıya yüklenmedi: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px boyutunda ve çözümlendiğinde __ocs_fmt_3__ MB gerektirir; bu sürümün __ocs_fmt_4__ MB görsel sınırını aştığından eklenemez. Sınır içindeki görseller gerektiğinde otomatik olarak düşürülmüş görüntü çözünürlüğüyle eklenir.
.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.
.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ı.
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Nesneleri seçin (__ocs_arg_count__ seçili, uygulamak için Enter):
@@ -3528,8 +3531,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: Etkin açıklama ölçeği kullanılamıyor.
.opening-ocs-discussions-for-help-and-questions = Yardım ve sorular için OCS Tartışmaları açılıyor...
.opening-ocs-web = OCS Web açılıyor...
.opening-the-command-reference = Komut başvurusu açılıyor...
.browser-cannot-open-typed-path = Tarayıcı yazılan bir yolu açamaz; dosyayı seçin.
.opening-the-command-reference = Komut başvurusu açılıyor...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu.
.opening-patreon-page = Patreon sayfası açılıyor...
.opening-feedback-page = Geri bildirim sayfası açılıyor...
+4 -1
View File
@@ -1985,6 +1985,9 @@ file =
.drawing-not-loaded-in-the-browser = 图形未加载到浏览器中:__ocs_fmt_0__。
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"为 __ocs_fmt_1__×__ocs_fmt_2__ 像素,解码需要 __ocs_fmt_3__ MB,超过此版本的 __ocs_fmt_4__ MB 图像上限,因此无法附着。上限内的图像在需要时会自动以降低的显示分辨率附着。
.image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。
.restore-all = 全部恢复
.restoring-drawing-s = 正在恢复 __ocs_fmt_0__ 绘图…
.session-had-open-drawing-s = 会话中有 __ocs_fmt_0__ 个打开的绘图。
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 选择对象(已选择 __ocs_arg_count__ 个,按 Enter 应用):
@@ -3561,8 +3564,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 活动注释表没有可用 。
.opening-ocs-discussions-for-help-and-questions = 开幕OCS 讨论帮助和问题...
.opening-ocs-web = 打开OCS网络...
.opening-the-command-reference = 正在打开命令参考...
.browser-cannot-open-typed-path = 浏览器无法打开键入的路径;请改为选择文件。
.opening-the-command-reference = 正在打开命令参考...
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。
.opening-patreon-page = 打开帕特伦的页面...
.opening-feedback-page = 打开反馈页面...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = 圖面未載入瀏覽器:__ocs_fmt_0__。
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"為 __ocs_fmt_1__×__ocs_fmt_2__ 像素,解碼需要 __ocs_fmt_3__ MB,超過此組建的 __ocs_fmt_4__ MB 影像上限,因此無法貼附。上限內的影像會在需要時自動以降低的顯示解析度貼附。
.image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。
.restore-all = 全部恢復
.restoring-drawing-s = 正在恢復 __ocs_fmt_0__ 繪圖…
.session-had-open-drawing-s = 工作階段中有 __ocs_fmt_0__ 個開啟的繪圖。
selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 選擇物件(已選擇 __ocs_arg_count__ 個,按 Enter 套用):
@@ -3573,8 +3576,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 活動註解表沒有可用 。
.opening-ocs-discussions-for-help-and-questions = 開幕OCS 討論幫助和問題...
.opening-ocs-web = 開啟OCS網路...
.opening-the-command-reference = 正在開啟命令參考...
.browser-cannot-open-typed-path = 瀏覽器無法開啟輸入的路徑;請改為選擇檔案。
.opening-the-command-reference = 正在開啟命令參考...
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。
.opening-patreon-page = 開啟帕特倫的頁面...
.opening-feedback-page = 開啟反饋頁面...
+25 -2
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# Canonical web bundle build: Trunk plus a fonts sanity sync.
# Canonical web bundle build: Trunk plus a fonts sanity sync and a service
# worker cache-version stamp.
#
# Trunk's copy-dir pipeline always copies web/fonts in full, but its final
# "apply staging to dist" step wipes the old dist and then moves dist/app/.stage
@@ -11,6 +12,13 @@
# successful Trunk build and verifies every font byte-for-byte, so a bundle
# produced through it can never ship without the font set.
#
# The wrapper also stamps the service worker's cache version: web/sw.js ships
# a "v1" placeholder (fine for `trunk serve`), but a deployed bundle must get
# a fresh cache name on every build or clients would keep serving the previous
# deploy's ~55 MB of cached assets. The hash covers every file in the finished
# bundle (fonts re-sync included, sw.js itself excluded), so any content
# change — or any Trunk re-hash of the wasm — produces a new version.
#
# Usage: sh scripts/build-web.sh [extra trunk build args]
# local: sh scripts/build-web.sh
# CI: sh scripts/build-web.sh --locked --html-output index.html web-app.html
@@ -46,4 +54,19 @@ for font in web/fonts/*.ttf; do
}
count=$((count + 1))
done
echo "web bundle built; verified $count fonts in dist/app/fonts"
# Stamp the service worker's cache version from the finished bundle. Every
# file in dist/app except sw.js itself feeds the hash: the deployed sw.js
# then carries a cache name unique to exactly this bundle's contents, and its
# activate handler deletes any older cache.
[ -f dist/app/sw.js ] || {
echo "build-web.sh: dist/app/sw.js missing — is web/sw.js still listed as a copy-file link in web-app.html?" >&2
exit 1
}
bundle_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 = \"$bundle_hash\";/" dist/app/sw.js
grep -q "^const CACHE_VERSION = \"$bundle_hash\";" dist/app/sw.js || {
echo "error: failed to stamp the cache version into dist/app/sw.js" >&2
exit 1
}
echo "web bundle built; verified $count fonts in dist/app/fonts; sw.js cache version $bundle_hash"
+77
View File
@@ -736,6 +736,25 @@ pub(super) struct OpenCADStudio {
/// prompt still has to offer, one at a time.
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
/// Snapshots a "Restore all" click still expects to settle (opened,
/// failed, cancelled, unreadable, or rejected by the open queue). Each
/// settle funnels through `queue_next_web_autosave_prompt`, which counts
/// this down; reaching zero arms `web_session_focus_pending`.
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: usize,
/// A Restore-all batch finished settling — apply the remembered
/// session's active tab at the next message boundary, after the last
/// restored open has actually inserted its tab (`web_autosave_opened`
/// runs before that insertion, so applying there would pick against a
/// tab list still missing the last restore).
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: bool,
/// The tab list the previous session remembered (localStorage), loaded
/// after the boot autosave scan. Advisory only: it feeds the restore
/// modal's session line and the post-restore tab selection — nothing is
/// ever auto-opened from it.
#[cfg(target_arch = "wasm32")]
web_session: Option<crate::io::web_session::WebSession>,
/// The snapshot whose recovered open is in flight: `(autosave key,
/// open id)`. The OPFS copy is the only one, so it is deleted only
/// once the open with exactly that id succeeds — identity, never the
@@ -2045,6 +2064,11 @@ pub enum Message {
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
#[cfg(target_arch = "wasm32")]
WebRecentStored(Result<PathBuf, String>),
/// Web: the `?file=` boot deep link settled — its bytes entered the
/// guarded open queue, or a notice/error text for the command line
/// (recent copy missing, refused or failed fetch).
#[cfg(target_arch = "wasm32")]
WebLinkSettled(crate::io::web_link::LinkOutcome),
/// Web: the boot scan finished — these autosave snapshots are newer than
/// the drawings' last saved copies (newest first).
#[cfg(target_arch = "wasm32")]
@@ -2052,6 +2076,12 @@ pub enum Message {
/// Web: restore the offered autosave snapshot into a recovered tab.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestore,
/// Web: restore EVERY pending autosave snapshot — each is read and
/// enqueued through the guarded open queue with its own key (the poll
/// opens them sequentially); a snapshot is still consumed only after its
/// own open succeeds.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestoreAll,
/// Web: delete the offered autosave snapshot (and offer the next, if any).
#[cfg(target_arch = "wasm32")]
WebAutosaveDiscard,
@@ -3895,6 +3925,12 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec::new(),
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: 0,
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: false,
#[cfg(target_arch = "wasm32")]
web_session: None,
#[cfg(target_arch = "wasm32")]
web_autosave_consumed: None,
#[cfg(target_arch = "wasm32")]
web_autosave_clear_after: Vec::new(),
@@ -4443,6 +4479,46 @@ impl OpenCADStudio {
crate::io::web_autosave::scan(),
Message::WebAutosavesScanned,
);
// URL deep link (`?file=…`): a shareable/embeddable link that opens a
// drawing at boot. An http(s) URL is fetched with the same capped
// streaming reader the remote-image fetch uses and handed to the
// guarded drop-open queue; a bare name reads the OPFS recent copy the
// same way `open_recent_web` does. Everything else (other schemes,
// non-.dwg/.dxf URL tails) is refused with a console warning — the
// same scheme gate the image fetch applies. Pure parsing/validation
// lives in `io::web_link` and is unit-tested there.
let deep_link: Task<Message> = {
let search = web_sys::window()
.and_then(|window| window.location().search().ok())
.unwrap_or_default();
match crate::io::web_link::parse_link(&search) {
crate::io::web_link::ParsedLink::Target(target) => {
// Opening a URL from the query string is an implicit trust
// decision: name the origin before the fetch starts (info
// only — the bytes still go through the parse + recovery
// machinery like any other open). Same-origin links stay
// quiet: that drawing already comes from this site.
if let crate::io::web_link::LinkTarget::Url { url, name } = &target {
if !crate::io::web_link::same_origin(url) {
let origin = crate::io::web_link::display_origin(url)
.unwrap_or_else(|| url.clone());
s.command_line
.push_info(&format!("Opening {name} from {origin}…"));
}
}
Task::perform(crate::io::web_link::open_link(target), |outcome| {
Message::WebLinkSettled(outcome)
})
}
crate::io::web_link::ParsedLink::Ignored(refusal, value) => {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
&crate::io::web_link::refusal_warning(refusal, &value),
));
Task::none()
}
crate::io::web_link::ParsedLink::Absent => Task::none(),
}
};
(
s,
Task::batch([
@@ -4453,6 +4529,7 @@ impl OpenCADStudio {
discussions,
thumbs_fetch,
autosave_scan,
deep_link,
]),
)
}
+4
View File
@@ -141,6 +141,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if let Some(tab_id) = tab_id {
v4_support::on_tab_closed(tab_id);
}
// The tab list just shrank: refresh the remembered session
// list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
Task::none()
}
+4
View File
@@ -240,6 +240,10 @@ impl OpenCADStudio {
// last selection. #21.
self.sync_ribbon_layers();
self.sync_ribbon_from_selection();
// The tab list just shrank: refresh the remembered
// session list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
return Task::batch([close_win, self.continue_tab_close_queue()]);
}
Some(crate::app::PendingClose::Quit) => {
+116 -2
View File
@@ -1698,6 +1698,10 @@ impl OpenCADStudio {
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
// The tab list just changed (and the active tab with it): remember
// it for the next boot's restore flow (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
let pending_open_task = if let Some(report) = recovery_report {
self.recovery_report = Some(report);
self.active_modal = Some(crate::app::ModalKind::Recovery);
@@ -2231,6 +2235,9 @@ impl OpenCADStudio {
);
self.tabs[i].scene.document.version = version;
self.tabs[i].recovery_save_as_required = false;
// A save may have renamed the tab (Save As): refresh the remembered
// session list (web session memory).
self.persist_web_session();
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
&& self.tabs[i].edit_revision == edit_revision
&& self.tabs[i].scene.camera_generation == camera_generation;
@@ -3342,12 +3349,16 @@ impl OpenCADStudio {
/// restore prompt (behind any already-queued startup modal). An empty
/// scan — a fresh profile, or everything already cleaned up — must not
/// surface an empty "Recover Drawing" modal, so the prompt is only
/// queued when there is something to offer.
/// queued when there is something to offer. The remembered session list
/// is loaded right after the scan: it never opens anything itself, it
/// only names what the session had (a line in the restore modal) and
/// picks the active tab once restores complete.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosaves_scanned(
&mut self,
result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
) -> Task<Message> {
self.web_session = crate::io::web_session::load();
let Ok(mut entries) = result else {
// Best-effort feature: no storage access, no prompt.
return Task::none();
@@ -3384,6 +3395,44 @@ impl OpenCADStudio {
)
}
/// "Restore all": the whole pending list goes through the exact same
/// guarded path as a single restore — one read per snapshot, then
/// `enqueue_restore` with its own key, and the 50 ms poll opens them
/// sequentially. Nothing about consumption changes: each snapshot is
/// still deleted only when its own open succeeds (matched by open id),
/// and any failure (unreadable, parse error, cancel) keeps that
/// snapshot in OPFS for the next boot. The batch counts its settles in
/// `web_autosave_restore_all_remaining`; every settle path funnels
/// through `queue_next_web_autosave_prompt`, so the countdown — and the
/// remembered session's tab selection when it hits zero — needs no
/// extra bookkeeping sites.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_restore_all(&mut self) -> Task<Message> {
// Fewer than two pending: this is just Restore.
if self.web_autosave_pending.len() < 2 {
return self.on_web_autosave_restore();
}
let entries = std::mem::take(&mut self.web_autosave_pending);
self.web_autosave_restore_all_remaining = entries.len();
self.close_active_modal();
self.command_line.push_info(
crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(),
);
let reads = crate::io::web_autosave::restore_all_plan(&entries)
.into_iter()
.map(|(open_name, key)| {
// Same contract as the single restore: the key travels with
// the bytes so the deletion marker is recorded by open id.
let read_key = key.clone();
Task::perform(
async move { crate::io::web_autosave::read(&read_key).await },
move |result| Message::WebAutosaveBytes(open_name, key, result),
)
})
.collect::<Vec<_>>();
Task::batch(reads)
}
/// The recovered drawing opened: its autosave snapshot — held back until
/// now — can finally be deleted, and the next queued snapshot can be
/// offered. The caller already matched the marker to this open's id.
@@ -3477,14 +3526,79 @@ impl OpenCADStudio {
}
/// Re-offer the remaining boot snapshots once the current one settled.
/// No-op when none are left (or the modal is already queued).
/// No-op when none are left (or the modal is already queued). This is
/// also the single funnel every restore settle runs through (opened,
/// failed, cancelled, unreadable, or queue-rejected), so a Restore-all
/// batch counts down here — and when its last snapshot settles, the
/// remembered session decides which tab ends up active.
#[cfg(target_arch = "wasm32")]
pub(super) fn queue_next_web_autosave_prompt(&mut self) {
if self.web_autosave_restore_all_remaining > 0 {
self.web_autosave_restore_all_remaining -= 1;
if self.web_autosave_restore_all_remaining == 0 {
// Applied at the next message boundary: the last restore's
// settle (`web_autosave_opened`) runs BEFORE its own open
// inserts the tab, so focusing here could not see it yet.
self.web_session_focus_pending = true;
}
}
if !self.web_autosave_pending.is_empty() {
self.queue_autosave_restore_prompt();
}
}
/// A Restore-all batch has fully settled: focus the tab the crashed
/// session remembered as active, if it actually came back (or is open
/// under its own name). Restored tabs carry the "(recovered)" marker —
/// `recovered_name` maps the remembered name onto it idempotently. A
/// no-op without remembered memory, or when the remembered tab did not
/// return; the last-opened tab then stays active, which is fine. Called
/// from the after-message hook (`web_session_focus_pending`), so the
/// whole settle batch — including the last tab insertion — is done.
#[cfg(target_arch = "wasm32")]
pub(super) fn select_remembered_session_tab(&mut self) {
let Some(session) = self.web_session.as_ref() else {
return;
};
let Some(remembered) = session.tabs.get(session.active) else {
return;
};
let recovered = crate::io::web_autosave::recovered_name(remembered);
let Some(index) = self.tabs.iter().position(|tab| {
let name = tab.tab_display_name();
name == remembered.as_str() || name == recovered
}) else {
return;
};
self.active_tab = index;
// The active tab changed under the ribbon; re-sync its chips like
// every other tab switch does (#21).
self.sync_ribbon_layers();
self.sync_ribbon_styles();
self.sync_ribbon_from_selection();
}
/// Remember the open-tab list (names + active index) in localStorage —
/// the web session memory the next boot's restore flow consults. Cheap
/// by design: a small `Vec<String>` + index, rewritten in full. Called
/// on every save/open/close; the Start tab is not a drawing and is
/// skipped, with the active index remapped onto the filtered list.
#[cfg(target_arch = "wasm32")]
pub(super) fn persist_web_session(&self) {
let mut names: Vec<String> = Vec::new();
let mut active = 0;
for (index, tab) in self.tabs.iter().enumerate() {
if tab.is_start {
continue;
}
if index == self.active_tab {
active = names.len();
}
names.push(tab.tab_display_name());
}
crate::io::web_session::persist(&names, active);
}
/// Delete the `.sv$` autosave recovery files for all open drawings. They
/// exist only to survive a crash, so a clean save or exit removes them.
pub(in crate::app) fn cleanup_autosaves(&mut self) {
+24
View File
@@ -291,6 +291,14 @@ impl OpenCADStudio {
let task = self.update_inner(msg);
self.refresh_gpu_status();
self.show_next_startup_modal();
// A Restore-all batch finished settling on this message: now that
// the last restored open has inserted its tab, focus the tab the
// remembered session had active (web session memory).
#[cfg(target_arch = "wasm32")]
if self.web_session_focus_pending {
self.web_session_focus_pending = false;
self.select_remembered_session_tab();
}
self.sync_open_command_history();
// Close the document-level first-touch transaction started by
// push_undo_snapshot at this message boundary.
@@ -1135,11 +1143,27 @@ impl OpenCADStudio {
}
},
// The `?file=` boot deep link settled. Queued bytes are drained by
// the control poll's drop queue like any drop — nothing to say
// until that open starts (or its parse error surfaces there).
#[cfg(target_arch = "wasm32")]
Message::WebLinkSettled(outcome) => {
use crate::io::web_link::LinkOutcome;
match outcome {
LinkOutcome::Queued => {}
LinkOutcome::Info(text) => self.command_line.push_info(&text),
LinkOutcome::Error(text) => self.command_line.push_error(&text),
}
Task::none()
},
#[cfg(target_arch = "wasm32")]
Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveRestore => self.on_web_autosave_restore(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveRestoreAll => self.on_web_autosave_restore_all(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDefer => self.on_web_autosave_defer(),
+62 -23
View File
@@ -1599,8 +1599,15 @@ impl OpenCADStudio {
.first()
.map(|entry| entry.name.clone())
.unwrap_or_default();
let pending = self.web_autosave_pending.len();
// What the crashed session had open, when it remembered.
let session_drawings = self
.web_session
.as_ref()
.map(|session| session.tabs.len())
.filter(|count| *count > 0);
automatic_flow(ex, |flow| {
autosave_restore_dialog_window(&name, flow)
autosave_restore_dialog_window(&name, pending, session_drawings, flow)
})
}
})
@@ -1777,36 +1784,68 @@ fn unsaved_changes_dialog_window(
/// Web crash-recovery prompt: an autosave snapshot of `name` is newer than
/// the drawing's last save. Restore opens it as a recovered tab; Discard
/// deletes the snapshot. Mirrors the unsaved-changes dialog's layout.
/// deletes the snapshot. With more than one snapshot still pending, "Restore
/// all" puts the whole remaining list through the same guarded open queue.
/// When the crashed session left a tab list behind, a muted line says how
/// much it had open. Mirrors the unsaved-changes dialog's layout.
#[cfg(target_arch = "wasm32")]
fn autosave_restore_dialog_window(
name: &str,
pending: usize,
session_drawings: Option<usize>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'static, Message> {
container(
column![
text(crate::tf!(
"\"{name}\" has an autosave copy newer than the last save."
))
.size(14),
iced::widget::Space::new().height(4),
let mut body = column![text(crate::tf!(
"\"{name}\" has an autosave copy newer than the last save."
))
.size(14)]
.spacing(0);
if let Some(count) = session_drawings {
body = body
.push(iced::widget::Space::new().height(4))
.push(
text(crate::tf!("Session had {count} open drawing(s).", count = count))
.size(12)
.style(dialog_muted_text_style),
);
}
body = body
.push(iced::widget::Space::new().height(4))
.push(
text(t!("Restore it as a recovered tab, or discard the copy."))
.size(12)
.style(dialog_muted_text_style),
iced::widget::Space::new().height(20),
row![
dialog_button(t!("Restore"), Message::WebAutosaveRestore, button::primary),
iced::widget::Space::new().width(8),
dialog_button(t!("Discard"), Message::WebAutosaveDiscard, button::danger),
],
]
.spacing(0),
)
.style(dialog_body_style)
.center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28])
.into()
)
.push(iced::widget::Space::new().height(20));
let mut buttons = row![dialog_button(
t!("Restore"),
Message::WebAutosaveRestore,
button::primary
)];
// Only worth its width once there is a batch to take in one click.
if pending > 1 {
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Restore all"),
Message::WebAutosaveRestoreAll,
button::secondary,
));
}
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Discard"),
Message::WebAutosaveDiscard,
button::danger,
));
body = body.push(buttons);
container(body)
.style(dialog_body_style)
.center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28])
.into()
}
#[cfg(not(target_arch = "wasm32"))]
+6
View File
@@ -30,6 +30,12 @@ pub mod xref;
pub(crate) mod web_autosave;
#[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;
// Minimal in-memory ZIP writer (classic format, flate2 CRC/deflate) used by
// the web ARCHIVE/ETRANSMIT transmittal download.
pub mod zip;
+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
);
}
}
+4 -1
View File
@@ -1887,7 +1887,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Opening OCS Discussions for help and questions..." => Some(("view", "opening-ocs-discussions-for-help-and-questions")),
"Opening OCS Web..." => Some(("view", "opening-ocs-web")),
"Opening the command reference..." => Some(("view", "opening-the-command-reference")),
"The browser cannot open a typed path; pick the file instead." => Some(("display", "browser-cannot-open-typed-path")),
"The browser cannot open a typed path; pick the file instead." => Some(("view", "browser-cannot-open-typed-path")),
"\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached." => Some(("file", "image-refusal-unknown-size")),
"\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("view", "already-loaded-different-contents-kept")),
"Opening Patreon page..." => Some(("view", "opening-patreon-page")),
@@ -4628,6 +4628,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Remote image \"{url}\" could not be fetched: the server returned HTTP {status}." => Some(("common", "remote-image-url-could-not-be-fetched-the-server-returned-http-status")),
"Remote image \"{url}\" was not loaded: it exceeds the 64 MB remote-image limit." => Some(("common", "remote-image-url-was-not-loaded-it-exceeds-the-64-mb-remote-image-limit")),
"Remote image \"{url}\" could not be fetched: the request timed out." => Some(("common", "remote-image-url-could-not-be-fetched-the-request-timed-out")),
"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")),
_ => None,
}
}
+7 -1
View File
@@ -1170,7 +1170,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;
@@ -1261,6 +1263,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`. scripts/build-web.sh 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 scripts/build-web.sh (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"},
});
})(),
);
});