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