diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9f9c4f4bd..286667b34 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -109,6 +109,16 @@ jobs: - name: Build web bundle run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html + - name: Stamp the service worker cache version + # web/sw.js ships a placeholder cache name; each deploy gets one + # derived from the bundle contents, so the new worker drops the + # previous deploy's cached assets when it activates. + run: | + test -f dist/app/sw.js + hash=$(find dist/app -type f ! -name sw.js -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c 1-12) + sed -i "s/^const CACHE_VERSION = \"[^\"]*\";/const CACHE_VERSION = \"$hash\";/" dist/app/sw.js + grep -q "^const CACHE_VERSION = \"$hash\";" dist/app/sw.js + - name: Check out current website uses: actions/checkout@v4 with: diff --git a/Cargo.toml b/Cargo.toml index 75b0e8189..4a068c28e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,6 +184,9 @@ web-sys = { version = "=0.3.85", features = [ "Event", "ReadableStreamDefaultReader", "ReadableStream", + # The ?file= boot deep link reads location.search and compares the link's + # origin against the page's (io::web_link). + "Location", ] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 1471c703d..88fdeb4c0 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2412,6 +2412,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT الموضع [استبدال/بادئة/لاحقة] <استبدال>: file = + .restore-all = استعادة الكل + .restoring-drawing-s = جارٍ استعادة __ocs_fmt_0__ رسم… + .session-had-open-drawing-s = كانت الجلسة تضم __ocs_fmt_0__ رسمًا مفتوحًا. .already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود. .image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = التحليل __ocs_fmt_0__ ملث · التنظيف __ocs_fmt_1__ ملث · الذاكرة المؤقتة __ocs_fmt_2__ ملث · المرجع الخارجي __ocs_fmt_3__ ملث · الإجمالي __ocs_fmt_4__ ملث diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 3f383ac42..e734713bd 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT поставяне [Замяна/Префикс/Суфикс] <Замяна>: file = + .restore-all = Възстанови всички + .restoring-drawing-s = Възстановяване на __ocs_fmt_0__ чертеж(а)… + .session-had-open-drawing-s = Сесията е имала __ocs_fmt_0__ отворени чертежа. .already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен. .image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = анализ __ocs_fmt_0__ms · изчистване __ocs_fmt_1__ms · кешове __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общо __ocs_fmt_4__ms diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 978750b6c..2fd02801b 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Umístění TCOUNT [přepsat/předpona/přípona] : file = + .restore-all = Obnovit vše + .restoring-drawing-s = Obnovování __ocs_fmt_0__ výkres(ů)… + .session-had-open-drawing-s = Relace měla otevřeno __ocs_fmt_0__ výkres(ů). .already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován. .image-refusal-unknown-size = „__ocs_fmt_0__“ nešlo změřit ze záhlaví a jeho dekódování by překročilo limit __ocs_fmt_1__ MB tohoto sestavení — nelze připojit. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analyzovat __ocs_fmt_0__ms · vyčistit __ocs_fmt_1__ms · mezipaměti __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · celkem __ocs_fmt_4__ms diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index d46247aee..18939b6ba 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>: file = + .restore-all = Alle wiederherstellen + .restoring-drawing-s = __ocs_fmt_0__ Zeichnung(en) werden wiederhergestellt… + .session-had-open-drawing-s = Die Sitzung hatte __ocs_fmt_0__ geöffnete Zeichnung(en). .already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten. .image-refusal-unknown-size = „__ocs_fmt_0__“ konnte anhand des Headers nicht vermessen werden, und das Dekodieren überschreitet das __ocs_fmt_1__-MB-Limit dieses Builds — kann nicht angehängt werden. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analysiere __ocs_fmt_0__ms · Säubere __ocs_fmt_1__ms · Puffer __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · gesamt __ocs_fmt_4__ms diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 8ed57c8cd..67f8deb20 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT τοποθέτηση [Αντικατάσταση/Πρόθεμα/Κατάληξη] <Αντικατάσταση>: file = + .restore-all = Επαναφορά όλων + .restoring-drawing-s = Επαναφορά __ocs_fmt_0__ σχέδιο(α)… + .session-had-open-drawing-s = Η συνεδρία είχε __ocs_fmt_0__ ανοιχτά σχέδια. .already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο. .image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ανάλυση __ocs_fmt_0__ms · εκκαθάριση __ocs_fmt_1__ms · κρυφές μνήμες __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · σύνολο __ocs_fmt_4__ms diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 649a089b3..6d9791fa9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2403,6 +2403,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT placement [Overwrite/Prefix/Suffix] : file = + .restore-all = Restore all + .restoring-drawing-s = Restoring __ocs_fmt_0__ drawing(s)… + .session-had-open-drawing-s = Session had __ocs_fmt_0__ open drawing(s). .already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept. .image-refusal-unknown-size = "__ocs_fmt_0__" could not be sized from its header and decoding it exceeded the __ocs_fmt_1__ MB image limit of this build — it cannot be attached. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · purge __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · total __ocs_fmt_4__ms diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a89f60e35..a7af6436f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT colocación [Sobrescribir/Prefijo/Sufijo] : file = + .restore-all = Restaurar todo + .restoring-drawing-s = Restaurando __ocs_fmt_0__ dibujos… + .session-had-open-drawing-s = La sesión tenía __ocs_fmt_0__ dibujos abiertos. .already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente. .image-refusal-unknown-size = "__ocs_fmt_0__" no pudo medirse por su cabecera y decodificarlo excedería el límite de __ocs_fmt_1__ MB de esta build — no se puede adjuntar. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análisis __ocs_fmt_0__ ms · limpieza __ocs_fmt_1__ ms · cachés __ocs_fmt_2__ ms · ref. ext. __ocs_fmt_3__ ms · total __ocs_fmt_4__ ms diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 7ea76797c..6dbea0f9c 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT-sijoittelu [Korvaa/etuliite/liite] : file = + .restore-all = Palauta kaikki + .restoring-drawing-s = Palautetaan __ocs_fmt_0__ piirustusta… + .session-had-open-drawing-s = Istunnossa oli __ocs_fmt_0__ avointa piirustusta. .already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin. .image-refusal-unknown-size = Kohteen "__ocs_fmt_0__" kokoa ei voitu mitata otsakkeesta ja sen purkaminen ylittäisi tämän käännöksen __ocs_fmt_1__ MB kuvarajan — sitä ei voi liittää. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = jäsentää __ocs_fmt_0__ms · tyhjentää __ocs_fmt_1__ms · välimuistit __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · yhteensä __ocs_fmt_4__ms diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0340544c1..2383822d8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Position [Remplacer/Préfixe/Suffixe] : file = + .restore-all = Tout restaurer + .restoring-drawing-s = Restauration de __ocs_fmt_0__ dessin(s)… + .session-had-open-drawing-s = La session avait __ocs_fmt_0__ dessin(s) ouvert(s). .already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé. .image-refusal-unknown-size = "__ocs_fmt_0__" n’a pas pu être mesuré par son en-tête et son décodage dépasserait la limite de __ocs_fmt_1__ Mo de cette version — impossible de l’attacher. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = Analyse : __ocs_fmt_0__ ms, purge : __ocs_fmt_1__ ms, caches : __ocs_fmt_2__ ms, références externes : __ocs_fmt_3__ ms, total : __ocs_fmt_4__ ms diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 83b952608..23ef882d5 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2388,6 +2388,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT स्थान [अधिलेखित/उपसर्ग/प्रत्यय] <अधिलेखित>: file = + .restore-all = सभी पुनर्स्थापित करें + .restoring-drawing-s = __ocs_fmt_0__ ड्रॉइंग पुनर्स्थापित की जा रही हैं… + .session-had-open-drawing-s = सत्र में __ocs_fmt_0__ ड्रॉइंग खुली थीं। .already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई। .image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता। .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = पार्स __ocs_fmt_0__ms · purge __ocs_fmt_1__Ms · कैश __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · कुल __ocs_fmt_4__ms diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d54254d94..c2d2672a7 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2412,6 +2412,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT elhelyezés [Overwrite (Felülírás) / Prefix (Előtag) / Suffix (Utótag)] : file = + .restore-all = Összes visszaállítása + .restoring-drawing-s = __ocs_fmt_0__ rajz(ok) visszaállítása… + .session-had-open-drawing-s = A munkamenetben __ocs_fmt_0__ rajz volt megnyitva. .already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve. .image-refusal-unknown-size = A(z) "__ocs_fmt_0__" nem mérhető a fejlécéből, és dekódolása meghaladná ezen build __ocs_fmt_1__ MB-os képhatárát — nem csatolható. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = elemzése __ocs_fmt_0__ms · __ocs_fmt_1__ms törlése · gyorsítótárak __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · összesen __ocs_fmt_4__ms diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index cb599f7a1..7a774b884 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Posizionamento TCOUNT [Sovrascrivi/Prefisso/Suffisso] : file = + .restore-all = Ripristina tutto + .restoring-drawing-s = Ripristino di __ocs_fmt_0__ disegno/i… + .session-had-open-drawing-s = La sessione aveva __ocs_fmt_0__ disegni aperti. .already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto. .image-refusal-unknown-size = "__ocs_fmt_0__" non è stato misurabile dall’intestazione e decodificarlo supererebbe il limite di __ocs_fmt_1__ MB di questa build — impossibile allegarlo. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizza __ocs_fmt_0__ms · elimina __ocs_fmt_1__ms · memorizza nella cache __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totale __ocs_fmt_4__ms diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index cd5e7d900..a82fcc7e4 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2390,6 +2390,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 配置 [Overwrite/Prefix/Suffix] : file = + .restore-all = すべて復元 + .restoring-drawing-s = __ocs_fmt_0__ 件の図面を復元しています… + .session-had-open-drawing-s = セッションでは __ocs_fmt_0__ 件の図面が開いていました。 .already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。 .image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ms · パージ __ocs_fmt_1__ms · キャッシュ __ocs_fmt_2__ms · 外部参照 __ocs_fmt_3__ms · 合計 __ocs_fmt_4__ms diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index f09d9cd13..4b1639631 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 배치 [덮어쓰기/접두사/접미사] : file = + .restore-all = 모두 복원 + .restoring-drawing-s = __ocs_fmt_0__ 도면을 복원하는 중… + .session-had-open-drawing-s = 세션에서 __ocs_fmt_0__ 도면이 열려 있었습니다. .already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다. .image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = __ocs_fmt_0__ms 구문 분석 · __ocs_fmt_1__ms 제거 · 캐시 __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · 총 __ocs_fmt_4__ms diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index c06f62e67..3f2869168 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] : file = + .restore-all = Alles herstellen + .restoring-drawing-s = __ocs_fmt_0__ tekening(en) worden hersteld… + .session-had-open-drawing-s = De sessie had __ocs_fmt_0__ tekening(en) open. .already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard. .image-refusal-unknown-size = "__ocs_fmt_0__" kon niet via de header gemeten worden en decoderen overschrijdt de __ocs_fmt_1__ MB-limiet van deze build — kan niet worden toegevoegd. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ontleed __ocs_fmt_0__ms · zuivering __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totaal __ocs_fmt_4__ms diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 53c7444d7..2a9252bcb 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Umieszczenie TCOUNT [Nadpisanie/Przedrostek/Sufiks] : file = + .restore-all = Przywróć wszystkie + .restoring-drawing-s = Przywracanie __ocs_fmt_0__ rysunków… + .session-had-open-drawing-s = Sesja miała __ocs_fmt_0__ otwartych rysunków. .already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik. .image-refusal-unknown-size = „__ocs_fmt_0__” nie dał się zmierzyć z nagłówka, a jego dekodowanie przekroczy limit __ocs_fmt_1__ MB tej kompilacji — nie można go dołączyć. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizuj __ocs_fmt_0__ms · usuwaj __ocs_fmt_1__ms · pamięci podręczne __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · łącznie __ocs_fmt_4__ms diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 2cc214d18..39e6705f7 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2400,6 +2400,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT posicionamento [Sobrescrever/Prefixo/Sufixo] : file = + .restore-all = Restaurar tudo + .restoring-drawing-s = Restaurando __ocs_fmt_0__ desenhos… + .session-had-open-drawing-s = A sessão tinha __ocs_fmt_0__ desenhos abertos. .already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido. .image-refusal-unknown-size = "__ocs_fmt_0__" não pôde ser medido pelo cabeçalho e a sua descodificação excedeu o limite de __ocs_fmt_1__ MB desta build — não pode ser anexado. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análise __ocs_fmt_0__ms · limpeza __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · ref. externa __ocs_fmt_3__ms · total __ocs_fmt_4__ms diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index da97b93c5..a7e1b4e7e 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2403,6 +2403,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Размещение [Заменить/Префикс/Суффикс] <Заменить>: file = + .restore-all = Восстановить все + .restoring-drawing-s = Восстановление __ocs_fmt_0__ чертежа(ей)… + .session-had-open-drawing-s = В сессии было открыто __ocs_fmt_0__ чертежа(ей). .already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён. .image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · чистка __ocs_fmt_1__ms · кэши __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общая __ocs_fmt_4__ms diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 83f572098..24cdc64ac 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2384,6 +2384,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Yerleştirme [Üzerine yaz/Önek/Sonek] <Üzerine yaz>: file = + .restore-all = Tümünü geri yükle + .restoring-drawing-s = __ocs_fmt_0__ çizim geri yükleniyor… + .session-had-open-drawing-s = Oturumda __ocs_fmt_0__ açık çizim vardı. .already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu. .image-refusal-unknown-size = "__ocs_fmt_0__" başlığından ölçülemedi ve kodu çözülürse bu sürümün __ocs_fmt_1__ MB görüntü limiti aşılır — eklenemez. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ayrıştırma __ocs_fmt_0__ ms · temizleme __ocs_fmt_1__ ms · önbellekler __ocs_fmt_2__ ms · xref __ocs_fmt_3__ ms · toplam __ocs_fmt_4__ ms diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 373c75975..96cd99051 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2387,6 +2387,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆盖/前缀/后缀] <覆盖>: file = + .restore-all = 全部恢复 + .restoring-drawing-s = 正在恢复 __ocs_fmt_0__ 绘图… + .session-had-open-drawing-s = 会话中有 __ocs_fmt_0__ 个打开的绘图。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。 .image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 缓存 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 总计 __ocs_fmt_4__ 毫秒 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 9dc7d60cd..ffa1109f3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2399,6 +2399,9 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆蓋/前綴/後綴] <覆蓋>: file = + .restore-all = 全部恢復 + .restoring-drawing-s = 正在恢復 __ocs_fmt_0__ 繪圖… + .session-had-open-drawing-s = 工作階段中有 __ocs_fmt_0__ 個開啟的繪圖。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。 .image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 快取 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 總計 __ocs_fmt_4__ 毫秒 diff --git a/src/app/mod.rs b/src/app/mod.rs index eeab4a304..e918409ba 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -927,6 +927,25 @@ pub(super) struct OpenCADStudio { /// prompt still has to offer, one at a time. #[cfg(target_arch = "wasm32")] web_autosave_pending: Vec, + /// Snapshots a "Restore all" click still expects to settle (opened, + /// failed, cancelled, unreadable, or rejected by the open queue). Each + /// settle funnels through `queue_next_web_autosave_prompt`, which counts + /// this down; reaching zero arms `web_session_focus_pending`. + #[cfg(target_arch = "wasm32")] + web_autosave_restore_all_remaining: usize, + /// A Restore-all batch finished settling — apply the remembered + /// session's active tab at the next message boundary, after the last + /// restored open has actually inserted its tab (`web_autosave_opened` + /// runs before that insertion, so applying there would pick against a + /// tab list still missing the last restore). + #[cfg(target_arch = "wasm32")] + web_session_focus_pending: bool, + /// The tab list the previous session remembered (localStorage), loaded + /// after the boot autosave scan. Advisory only: it feeds the restore + /// modal's session line and the post-restore tab selection — nothing is + /// ever auto-opened from it. + #[cfg(target_arch = "wasm32")] + web_session: Option, /// The snapshot whose recovered open is in flight: `(autosave key, /// open id)`. The OPFS copy is the only one, so it is deleted only /// once the open with exactly that id succeeds — identity, never the @@ -2346,6 +2365,11 @@ pub enum Message { /// Web: an asynchronous OPFS copy written after Save is ready for recents. #[cfg(target_arch = "wasm32")] WebRecentStored(Result), + /// Web: the `?file=` boot deep link settled — its bytes entered the + /// guarded open queue, or a notice/error text for the command line + /// (recent copy missing, refused or failed fetch). + #[cfg(target_arch = "wasm32")] + WebLinkSettled(crate::io::web_link::LinkOutcome), /// Web: the boot scan finished — these autosave snapshots are newer than /// the drawings' last saved copies (newest first). #[cfg(target_arch = "wasm32")] @@ -2353,6 +2377,12 @@ pub enum Message { /// Web: restore the offered autosave snapshot into a recovered tab. #[cfg(target_arch = "wasm32")] WebAutosaveRestore, + /// Web: restore EVERY pending autosave snapshot — each is read and + /// enqueued through the guarded open queue with its own key (the poll + /// opens them sequentially); a snapshot is still consumed only after its + /// own open succeeds. + #[cfg(target_arch = "wasm32")] + WebAutosaveRestoreAll, /// Web: delete the offered autosave snapshot (and offer the next, if any). #[cfg(target_arch = "wasm32")] WebAutosaveDiscard, @@ -4524,6 +4554,12 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] web_autosave_pending: Vec::new(), #[cfg(target_arch = "wasm32")] + web_autosave_restore_all_remaining: 0, + #[cfg(target_arch = "wasm32")] + web_session_focus_pending: false, + #[cfg(target_arch = "wasm32")] + web_session: None, + #[cfg(target_arch = "wasm32")] web_autosave_consumed: None, #[cfg(target_arch = "wasm32")] web_autosave_clear_after: Vec::new(), @@ -5119,6 +5155,46 @@ impl OpenCADStudio { crate::io::web_autosave::scan(), Message::WebAutosavesScanned, ); + // URL deep link (`?file=…`): a shareable/embeddable link that opens a + // drawing at boot. An http(s) URL is fetched with the same capped + // streaming reader the remote-image fetch uses and handed to the + // guarded drop-open queue; a bare name reads the OPFS recent copy the + // same way `open_recent_web` does. Everything else (other schemes, + // non-.dwg/.dxf URL tails) is refused with a console warning — the + // same scheme gate the image fetch applies. Pure parsing/validation + // lives in `io::web_link` and is unit-tested there. + let deep_link: Task = { + let search = web_sys::window() + .and_then(|window| window.location().search().ok()) + .unwrap_or_default(); + match crate::io::web_link::parse_link(&search) { + crate::io::web_link::ParsedLink::Target(target) => { + // Opening a URL from the query string is an implicit trust + // decision: name the origin before the fetch starts (info + // only — the bytes still go through the parse + recovery + // machinery like any other open). Same-origin links stay + // quiet: that drawing already comes from this site. + if let crate::io::web_link::LinkTarget::Url { url, name } = &target { + if !crate::io::web_link::same_origin(url) { + let origin = crate::io::web_link::display_origin(url) + .unwrap_or_else(|| url.clone()); + s.command_line + .push_info(&format!("Opening {name} from {origin}…")); + } + } + Task::perform(crate::io::web_link::open_link(target), |outcome| { + Message::WebLinkSettled(outcome) + }) + } + crate::io::web_link::ParsedLink::Ignored(refusal, value) => { + web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str( + &crate::io::web_link::refusal_warning(refusal, &value), + )); + Task::none() + } + crate::io::web_link::ParsedLink::Absent => Task::none(), + } + }; ( s, Task::batch([ @@ -5129,6 +5205,7 @@ impl OpenCADStudio { discussions, thumbs_fetch, autosave_scan, + deep_link, ]), ) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 9e13601a1..9ba516246 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -219,6 +219,10 @@ impl OpenCADStudio { if let Some(tab_id) = tab_id { v4_support::on_tab_closed(tab_id); } + // The tab list just shrank: refresh the remembered session list + // (web session memory). + #[cfg(target_arch = "wasm32")] + self.persist_web_session(); Task::none() } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 12f4232a1..f6691cacf 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -225,6 +225,10 @@ impl OpenCADStudio { // last selection. #21. self.sync_ribbon_layers(); self.sync_ribbon_from_selection(); + // The tab list just shrank: refresh the remembered + // session list (web session memory). + #[cfg(target_arch = "wasm32")] + self.persist_web_session(); return Task::batch([close_win, self.continue_tab_close_queue()]); } Some(crate::app::PendingClose::Quit) => { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bc662022a..c18815d09 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2248,6 +2248,10 @@ impl OpenCADStudio { .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); } self.opening.take(); + // The tab list just changed (and the active tab with it): remember + // it for the next boot's restore flow (web session memory). + #[cfg(target_arch = "wasm32")] + self.persist_web_session(); let pending_open_task = if let Some(report) = recovery_report { self.recovery_report = Some(report); self.active_modal = Some(crate::app::ModalKind::Recovery); @@ -2803,6 +2807,9 @@ impl OpenCADStudio { ); self.tabs[i].scene.document.version = version; self.tabs[i].recovery_save_as_required = false; + // A save may have renamed the tab (Save As): refresh the remembered + // session list (web session memory). + self.persist_web_session(); let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch && self.tabs[i].edit_revision == edit_revision && self.tabs[i].scene.camera_generation == camera_generation; @@ -3962,12 +3969,16 @@ impl OpenCADStudio { /// restore prompt (behind any already-queued startup modal). An empty /// scan — a fresh profile, or everything already cleaned up — must not /// surface an empty "Recover Drawing" modal, so the prompt is only - /// queued when there is something to offer. + /// queued when there is something to offer. The remembered session list + /// is loaded right after the scan: it never opens anything itself, it + /// only names what the session had (a line in the restore modal) and + /// picks the active tab once restores complete. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosaves_scanned( &mut self, result: Result, String>, ) -> Task { + self.web_session = crate::io::web_session::load(); let Ok(mut entries) = result else { // Best-effort feature: no storage access, no prompt. return Task::none(); @@ -4004,6 +4015,44 @@ impl OpenCADStudio { ) } + /// "Restore all": the whole pending list goes through the exact same + /// guarded path as a single restore — one read per snapshot, then + /// `enqueue_restore` with its own key, and the 50 ms poll opens them + /// sequentially. Nothing about consumption changes: each snapshot is + /// still deleted only when its own open succeeds (matched by open id), + /// and any failure (unreadable, parse error, cancel) keeps that + /// snapshot in OPFS for the next boot. The batch counts its settles in + /// `web_autosave_restore_all_remaining`; every settle path funnels + /// through `queue_next_web_autosave_prompt`, so the countdown — and the + /// remembered session's tab selection when it hits zero — needs no + /// extra bookkeeping sites. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_autosave_restore_all(&mut self) -> Task { + // Fewer than two pending: this is just Restore. + if self.web_autosave_pending.len() < 2 { + return self.on_web_autosave_restore(); + } + let entries = std::mem::take(&mut self.web_autosave_pending); + self.web_autosave_restore_all_remaining = entries.len(); + self.close_active_modal(); + self.command_line.push_info( + crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(), + ); + let reads = crate::io::web_autosave::restore_all_plan(&entries) + .into_iter() + .map(|(open_name, key)| { + // Same contract as the single restore: the key travels with + // the bytes so the deletion marker is recorded by open id. + let read_key = key.clone(); + Task::perform( + async move { crate::io::web_autosave::read(&read_key).await }, + move |result| Message::WebAutosaveBytes(open_name, key, result), + ) + }) + .collect::>(); + Task::batch(reads) + } + /// The recovered drawing opened: its autosave snapshot — held back until /// now — can finally be deleted, and the next queued snapshot can be /// offered. The caller already matched the marker to this open's id. @@ -4097,14 +4146,79 @@ impl OpenCADStudio { } /// Re-offer the remaining boot snapshots once the current one settled. - /// No-op when none are left (or the modal is already queued). + /// No-op when none are left (or the modal is already queued). This is + /// also the single funnel every restore settle runs through (opened, + /// failed, cancelled, unreadable, or queue-rejected), so a Restore-all + /// batch counts down here — and when its last snapshot settles, the + /// remembered session decides which tab ends up active. #[cfg(target_arch = "wasm32")] pub(super) fn queue_next_web_autosave_prompt(&mut self) { + if self.web_autosave_restore_all_remaining > 0 { + self.web_autosave_restore_all_remaining -= 1; + if self.web_autosave_restore_all_remaining == 0 { + // Applied at the next message boundary: the last restore's + // settle (`web_autosave_opened`) runs BEFORE its own open + // inserts the tab, so focusing here could not see it yet. + self.web_session_focus_pending = true; + } + } if !self.web_autosave_pending.is_empty() { self.queue_autosave_restore_prompt(); } } + /// A Restore-all batch has fully settled: focus the tab the crashed + /// session remembered as active, if it actually came back (or is open + /// under its own name). Restored tabs carry the "(recovered)" marker — + /// `recovered_name` maps the remembered name onto it idempotently. A + /// no-op without remembered memory, or when the remembered tab did not + /// return; the last-opened tab then stays active, which is fine. Called + /// from the after-message hook (`web_session_focus_pending`), so the + /// whole settle batch — including the last tab insertion — is done. + #[cfg(target_arch = "wasm32")] + pub(super) fn select_remembered_session_tab(&mut self) { + let Some(session) = self.web_session.as_ref() else { + return; + }; + let Some(remembered) = session.tabs.get(session.active) else { + return; + }; + let recovered = crate::io::web_autosave::recovered_name(remembered); + let Some(index) = self.tabs.iter().position(|tab| { + let name = tab.tab_display_name(); + name == remembered.as_str() || name == recovered + }) else { + return; + }; + self.active_tab = index; + // The active tab changed under the ribbon; re-sync its chips like + // every other tab switch does (#21). + self.sync_ribbon_layers(); + self.sync_ribbon_styles(); + self.sync_ribbon_from_selection(); + } + + /// Remember the open-tab list (names + active index) in localStorage — + /// the web session memory the next boot's restore flow consults. Cheap + /// by design: a small `Vec` + index, rewritten in full. Called + /// on every save/open/close; the Start tab is not a drawing and is + /// skipped, with the active index remapped onto the filtered list. + #[cfg(target_arch = "wasm32")] + pub(super) fn persist_web_session(&self) { + let mut names: Vec = Vec::new(); + let mut active = 0; + for (index, tab) in self.tabs.iter().enumerate() { + if tab.is_start { + continue; + } + if index == self.active_tab { + active = names.len(); + } + names.push(tab.tab_display_name()); + } + crate::io::web_session::persist(&names, active); + } + /// Delete the `.sv$` autosave recovery files for all open drawings. They /// exist only to survive a crash, so a clean save or exit removes them. pub(in crate::app) fn cleanup_autosaves(&mut self) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index dfc7cf614..7ccce051f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -481,6 +481,14 @@ impl OpenCADStudio { self.graphic_attributes.retain_selection(&selected); } self.show_next_startup_modal(); + // A Restore-all batch finished settling on this message: now that + // the last restored open has inserted its tab, focus the tab the + // remembered session had active (web session memory). + #[cfg(target_arch = "wasm32")] + if self.web_session_focus_pending { + self.web_session_focus_pending = false; + self.select_remembered_session_tab(); + } self.sync_open_command_history(); // Close the document-level first-touch transaction started by // push_undo_snapshot at this message boundary. @@ -1401,11 +1409,27 @@ impl OpenCADStudio { } }, + // The `?file=` boot deep link settled. Queued bytes are drained by + // the control poll's drop queue like any drop — nothing to say + // until that open starts (or its parse error surfaces there). + #[cfg(target_arch = "wasm32")] + Message::WebLinkSettled(outcome) => { + use crate::io::web_link::LinkOutcome; + match outcome { + LinkOutcome::Queued => {} + LinkOutcome::Info(text) => self.command_line.push_info(&text), + LinkOutcome::Error(text) => self.command_line.push_error(&text), + } + Task::none() + }, + #[cfg(target_arch = "wasm32")] Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result), #[cfg(target_arch = "wasm32")] Message::WebAutosaveRestore => self.on_web_autosave_restore(), #[cfg(target_arch = "wasm32")] + Message::WebAutosaveRestoreAll => self.on_web_autosave_restore_all(), + #[cfg(target_arch = "wasm32")] Message::WebAutosaveDiscard => self.on_web_autosave_discard(), #[cfg(target_arch = "wasm32")] Message::WebAutosaveDefer => self.on_web_autosave_defer(), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 80e38ec3e..8e297f43f 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -1817,8 +1817,15 @@ impl OpenCADStudio { .first() .map(|entry| entry.name.clone()) .unwrap_or_default(); + let pending = self.web_autosave_pending.len(); + // What the crashed session had open, when it remembered. + let session_drawings = self + .web_session + .as_ref() + .map(|session| session.tabs.len()) + .filter(|count| *count > 0); automatic_flow(ex, |flow| { - autosave_restore_dialog_window(&name, flow) + autosave_restore_dialog_window(&name, pending, session_drawings, flow) }) } }) @@ -2070,36 +2077,68 @@ fn unsaved_changes_dialog_window( /// Web crash-recovery prompt: an autosave snapshot of `name` is newer than /// the drawing's last save. Restore opens it as a recovered tab; Discard -/// deletes the snapshot. Mirrors the unsaved-changes dialog's layout. +/// deletes the snapshot. With more than one snapshot still pending, "Restore +/// all" puts the whole remaining list through the same guarded open queue. +/// When the crashed session left a tab list behind, a muted line says how +/// much it had open. Mirrors the unsaved-changes dialog's layout. #[cfg(target_arch = "wasm32")] fn autosave_restore_dialog_window( name: &str, + pending: usize, + session_drawings: Option, sizing: crate::ui::modal::ModalSizing, ) -> Element<'static, Message> { - container( - column![ - text(crate::tf!( - "\"{name}\" has an autosave copy newer than the last save." - )) - .size(14), - iced::widget::Space::new().height(4), + let mut body = column![text(crate::tf!( + "\"{name}\" has an autosave copy newer than the last save." + )) + .size(14)] + .spacing(0); + if let Some(count) = session_drawings { + body = body + .push(iced::widget::Space::new().height(4)) + .push( + text(crate::tf!("Session had {count} open drawing(s).", count = count)) + .size(12) + .style(dialog_muted_text_style), + ); + } + body = body + .push(iced::widget::Space::new().height(4)) + .push( text(t!("Restore it as a recovered tab, or discard the copy.")) .size(12) .style(dialog_muted_text_style), - iced::widget::Space::new().height(20), - row![ - dialog_button(t!("Restore"), Message::WebAutosaveRestore, button::primary), - iced::widget::Space::new().width(8), - dialog_button(t!("Discard"), Message::WebAutosaveDiscard, button::danger), - ], - ] - .spacing(0), - ) - .style(dialog_body_style) - .center_x(sizing.width) - .center_y(sizing.height) - .padding([24, 28]) - .into() + ) + .push(iced::widget::Space::new().height(20)); + let mut buttons = row![dialog_button( + t!("Restore"), + Message::WebAutosaveRestore, + button::primary + )]; + // Only worth its width once there is a batch to take in one click. + if pending > 1 { + buttons = buttons + .push(iced::widget::Space::new().width(8)) + .push(dialog_button( + t!("Restore all"), + Message::WebAutosaveRestoreAll, + button::secondary, + )); + } + buttons = buttons + .push(iced::widget::Space::new().width(8)) + .push(dialog_button( + t!("Discard"), + Message::WebAutosaveDiscard, + button::danger, + )); + body = body.push(buttons); + container(body) + .style(dialog_body_style) + .center_x(sizing.width) + .center_y(sizing.height) + .padding([24, 28]) + .into() } #[cfg(not(target_arch = "wasm32"))] diff --git a/src/io/mod.rs b/src/io/mod.rs index 5bfb1424c..a25434e25 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -36,6 +36,12 @@ pub(crate) mod web_autosave; mod web_worker; #[cfg(target_arch = "wasm32")] pub(crate) mod web_drop; +// Same split as web_autosave: the ?file= deep-link parser (and its tests) are +// pure and compile everywhere; the fetch/open half is wasm-only. +pub(crate) mod web_link; +// Same split again: the session-memory serialization half (and its tests) +// compile everywhere; the localStorage half is wasm-only. +pub(crate) mod web_session; pub mod windows_media; pub mod xref; pub mod xref_model; diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs index 775ebd21e..5939d2ea9 100644 --- a/src/io/web_autosave.rs +++ b/src/io/web_autosave.rs @@ -123,6 +123,20 @@ pub(crate) fn recovered_name(drawing: &str) -> String { format!("{stem}{RECOVERED_MARKER}.{ext}") } +/// The open plan for a "Restore all" batch: one `(recovered tab name, +/// snapshot key)` pair per pending snapshot, in the pending list's order — +/// the same order the one-at-a-time prompts would have offered them +/// (newest first), and the order the batch's reads are fired in. The keys +/// travel unchanged: each snapshot is consumed only by its own successful +/// open, exactly like a single restore. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) fn restore_all_plan(entries: &[WebAutosaveEntry]) -> Vec<(String, String)> { + entries + .iter() + .map(|entry| (recovered_name(&entry.name), entry.name.clone())) + .collect() +} + /// Overwrite (or create) the snapshot for `name`. The optional thumbnail is /// written alongside as `.thumb`; passing `None` removes any stale one. #[cfg(target_arch = "wasm32")] @@ -371,6 +385,30 @@ mod tests { ); } + #[test] + fn restore_all_keeps_the_pending_order_and_maps_only_the_names() { + let entries = vec![ + entry("new.dwg", 300.0), + entry("mid.dwg", 200.0), + entry("plan (recovered).dxf", 100.0), + ]; + let plan = restore_all_plan(&entries); + assert_eq!( + plan.iter().map(|(name, _)| name.as_str()).collect::>(), + [ + "new (recovered).dwg", + "mid (recovered).dwg", + "plan (recovered).dxf" + ] + ); + // Keys ride along unchanged — consumption stays keyed by the exact + // snapshot identity, never by the display name. + assert_eq!( + plan.iter().map(|(_, key)| key.as_str()).collect::>(), + ["new.dwg", "mid.dwg", "plan (recovered).dxf"] + ); + } + fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry { WebAutosaveEntry { name: name.to_string(), diff --git a/src/io/web_link.rs b/src/io/web_link.rs new file mode 100644 index 000000000..4c6423979 --- /dev/null +++ b/src/io/web_link.rs @@ -0,0 +1,475 @@ +//! Web URL deep links (`?file=`). +//! +//! The web build had no way to share or embed a link that opens a drawing: +//! `location.search` was never read. `?file=` now opens a drawing at +//! boot, where `` is a URL-encoded `http(s)` URL to fetch or a bare +//! drawing name to look up in the OPFS recent copies. +//! +//! The pure half (query parsing, scheme gate, trailing-name extraction) is +//! target-independent and unit-tested on the native host, mirroring how +//! `web_autosave` keeps its comparison logic testable; the fetch/open half is +//! wasm-only. Security mirrors the remote-raster fetch: only `http(s)` is +//! fetched (never `file:`, `javascript:`, or any other scheme — refused with +//! a console warning), the body is read with the same capped streaming reader +//! (64 MB cap, 8 s deadline, honest network/CORS/size/status classification), +//! and the URL must end in a `.dwg`/`.dxf` name like a drop. Fetched bytes +//! then enter the guarded drop-open queue (`web_drop::enqueue_open`), so the +//! deep link cannot bypass the open slot guard or the parse + recovery +//! machinery every other open goes through. + +/// What `?file=` asked for, after validation. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkTarget { + /// An absolute `http(s)` URL whose trailing path segment is a + /// `.dwg`/`.dxf` name; `name` is that decoded segment. + Url { url: String, name: String }, + /// A bare drawing name (no scheme) — resolved against the OPFS recent + /// copies, the same store `open_recent_web` reads. + RecentName(String), +} + +/// Why a present `?file=` value was refused. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkRefusal { + /// A scheme other than `http`/`https` (`file:`, `javascript:`, `data:`, + /// or a Windows drive-letter colon) — the same gate the remote-image + /// fetch applies; never fetched, only warned about. + Scheme, + /// Started with `http(s)://` but did not parse as an absolute URL. + BadUrl, + /// Parsed, but the trailing path segment is not a `.dwg`/`.dxf` name. + NotDrawing, +} + +/// Result of parsing the page's `location.search` for the deep-link param. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedLink { + /// No (non-empty) `file` param — boot proceeds normally. + Absent, + /// A validated target to open. + Target(LinkTarget), + /// Present but refused; the raw value is kept for the console warning. + Ignored(LinkRefusal, String), +} + +/// Extract and validate the `file` query parameter from a raw `search` +/// string (with or without the leading `?`). Values are form-decoded +/// (`%XX` and `+`), trimmed, and scheme-gated: `http(s)` URLs must parse and +/// end in a `.dwg`/`.dxf` path segment; any other scheme-like prefix is +/// refused (never dereferenced); anything else is treated as a bare drawing +/// name for the OPFS recent-copy lookup. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub fn parse_link(search: &str) -> ParsedLink { + let search = search.strip_prefix('?').unwrap_or(search); + // `form_urlencoded` yields `Cow` pairs in order; the FIRST `file` + // param decides — a duplicated one must not turn into two opens. + let value = url::form_urlencoded::parse(search.as_bytes()) + .find(|(key, _)| key == "file") + .map(|(_, value)| value.into_owned()); + let Some(value) = value else { + return ParsedLink::Absent; + }; + let value = value.trim(); + if value.is_empty() { + return ParsedLink::Absent; + } + let lower = value.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + let parsed = match url::Url::parse(value) { + Ok(parsed) => parsed, + Err(_) => return ParsedLink::Ignored(LinkRefusal::BadUrl, value.to_string()), + }; + return match trailing_drawing_name(&parsed) { + Some(name) => ParsedLink::Target(LinkTarget::Url { + url: parsed.to_string(), + name, + }), + None => ParsedLink::Ignored(LinkRefusal::NotDrawing, value.to_string()), + }; + } + if let Some(_scheme) = scheme_prefix(value) { + // Never dereference: the gate the remote-image fetch applies, so a + // `file:`/`javascript:`/drive-letter value dies here, warned only. + return ParsedLink::Ignored(LinkRefusal::Scheme, value.to_string()); + } + ParsedLink::Target(LinkTarget::RecentName(value.to_string())) +} + +/// The console warning for a refused param (mirrors the drop queues' +/// "ignored …" warnings, which also only reach the console). +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub fn refusal_warning(refusal: LinkRefusal, value: &str) -> String { + match refusal { + LinkRefusal::Scheme => { + format!("ocs: ignored ?file=\"{value}\" (only http(s) URLs are fetched)") + } + LinkRefusal::BadUrl => { + format!("ocs: ignored ?file=\"{value}\" (not a valid absolute URL)") + } + LinkRefusal::NotDrawing => { + format!("ocs: ignored ?file=\"{value}\" (URL must end in a .dwg/.dxf name)") + } + } +} + +/// RFC 3986 scheme prefix (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`) +/// of `value`, if any. A single letter before a colon matches (so +/// `C:\drawings\plan.dwg` is refused as a scheme, which is correct on the +/// web: a native path is meaningless there and must not become a fetch). +fn scheme_prefix(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() { + return None; + } + let end = bytes.iter().position(|&byte| byte == b':')?; + let scheme = &value[..end]; + let valid = !scheme.is_empty() + && scheme + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')); + valid.then(|| scheme.to_string()) +} + +/// The URL's trailing path segment, percent-decoded, when it is a +/// `.dwg`/`.dxf` drawing name. Same extension convention as the drop filter +/// (`web_drop::is_drawing_name`): a dot-less segment is its own extension. +fn trailing_drawing_name(url: &url::Url) -> Option { + // `Url::path()` keeps the percent-encoding, so decode it here; query and + // fragment are already excluded by `path()`. + let segment = percent_decode(url.path().rsplit('/').next().unwrap_or_default()); + if !is_drawing_name(&segment) { + return None; + } + Some(segment) +} + +/// Same convention as `web_drop::is_drawing_name` (which lives in the +/// wasm-gated drop module, so the rule is restated here for the pure parser). +fn is_drawing_name(name: &str) -> bool { + matches!( + name.rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "dwg" | "dxf" + ) +} + +/// Decode `%XX` escapes (NOT `+`, which path segments do not carry). Invalid +/// or truncated escapes pass through verbatim; undecodable bytes become +/// U+FFFD via lossy conversion — a name is display data, never a path. +fn percent_decode(value: &str) -> String { + fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let escape = (bytes[index] == b'%' && index + 2 < bytes.len()).then(|| { + (hex(bytes[index + 1]), hex(bytes[index + 2])) + }); + match escape { + Some((Some(high), Some(low))) => { + out.push(high * 16 + low); + index += 3; + } + _ => { + out.push(bytes[index]); + index += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +// ── Web open path ────────────────────────────────────────────────────────── + +/// How a deep link settled; the `WebLinkSettled` update arm surfaces it. +/// `pub` to match `WebAutosaveEntry` (public type in a `pub(crate)` module), +/// keeping the `Message` payload free of privacy warnings. +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone)] +pub enum LinkOutcome { + /// The bytes entered the guarded drop-open queue — the 50 ms poll opens + /// them exactly like a dropped file. + Queued, + /// Command-line notice (no drawing opened, nothing failed): the named + /// drawing is not in this browser's storage. + Info(String), + /// Command-line error: the fetch was refused or failed. + Error(String), +} + +/// Resolve the boot deep link: fetch the URL with the same capped streaming +/// reader the remote-image fetch uses, or read the OPFS recent copy, then +/// queue the bytes for the guarded open poll. Errors render the honest +/// reason (network/CORS, HTTP status, size cap, deadline). +#[cfg(target_arch = "wasm32")] +pub(crate) async fn open_link(target: LinkTarget) -> LinkOutcome { + match target { + LinkTarget::Url { url, name } => { + match crate::scene::model::image_model::fetch_url_bytes(&url).await { + Ok(bytes) => enqueue_or_full(name, bytes), + Err(error) => LinkOutcome::Error(fetch_error_text(&url, error)), + } + } + LinkTarget::RecentName(name) => match super::web_recent::read(&name).await { + Ok(bytes) => enqueue_or_full(name, bytes), + Err(_) => LinkOutcome::Info(format!( + "\"{name}\" is not in this browser's recent drawings; open or drop it once and the link can reopen it." + )), + }, + } +} + +/// Hand the bytes to the guarded open queue (the same door a drop uses), or +/// report a full queue — the fetch succeeded, so the loss must be visible. +#[cfg(target_arch = "wasm32")] +fn enqueue_or_full(name: String, bytes: Vec) -> LinkOutcome { + if super::web_drop::enqueue_open(name.clone(), bytes) { + LinkOutcome::Queued + } else { + LinkOutcome::Error(format!( + "Deep-linked drawing \"{name}\" could not be opened: the open queue is full." + )) + } +} + +/// Plain-English twin of the remote-image failure notes (those strings are +/// built inline in the poll arm and mention images); the classification +/// itself is shared via `RemoteFetchError`. +#[cfg(target_arch = "wasm32")] +fn fetch_error_text(url: &str, error: crate::scene::model::image_model::RemoteFetchError) -> String { + use crate::scene::model::image_model::RemoteFetchError; + match error { + RemoteFetchError::Network => format!( + "Deep-linked drawing \"{url}\" could not be fetched: the network request failed (offline, DNS, or CORS)." + ), + RemoteFetchError::Status(status) => format!( + "Deep-linked drawing \"{url}\" could not be fetched: the server returned HTTP {status}." + ), + RemoteFetchError::TooLarge => format!( + "Deep-linked drawing \"{url}\" was not opened: it exceeds the 64 MB remote-file limit." + ), + RemoteFetchError::Timeout => format!( + "Deep-linked drawing \"{url}\" could not be fetched: the request timed out." + ), + } +} + +/// Whether `url` shares the page's origin. Unparseable URLs (or a missing +/// window) read as foreign, so the trust notice errs on the loud side. +#[cfg(target_arch = "wasm32")] +pub(crate) fn same_origin(url: &str) -> bool { + let Some(page) = web_sys::window().and_then(|window| window.location().origin().ok()) else { + return false; + }; + match url::Url::parse(url) { + // `ascii_serialization` and `location.origin()` agree on omitting + // default ports, so the string comparison is exact. + Ok(parsed) => parsed.origin().ascii_serialization() == page, + Err(_) => false, + } +} + +/// The origin to show in the trust notice — the URL's own origin, falling +/// back to the full URL when it cannot be rendered. +#[cfg(target_arch = "wasm32")] +pub(crate) fn display_origin(url: &str) -> Option { + url::Url::parse(url) + .ok() + .map(|parsed| parsed.origin().ascii_serialization()) + .filter(|origin| !origin.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn url_target(search: &str) -> LinkTarget { + match parse_link(search) { + ParsedLink::Target(target) => target, + other => panic!("expected a target, got {other:?}"), + } + } + + #[test] + fn absent_when_there_is_no_file_param() { + assert_eq!(parse_link(""), ParsedLink::Absent); + assert_eq!(parse_link("?"), ParsedLink::Absent); + assert_eq!(parse_link("?other=plan.dwg"), ParsedLink::Absent); + assert_eq!(parse_link("?file="), ParsedLink::Absent); + assert_eq!(parse_link("?file=%20"), ParsedLink::Absent); + // Only the exact lowercase key opens; `File=` is not a deep link. + assert_eq!(parse_link("?File=plan.dwg"), ParsedLink::Absent); + // The first occurrence wins — a duplicate must not open two files. + assert_eq!( + parse_link("?file=&file=plan.dwg"), + ParsedLink::Absent, + "an empty first value is absent, not skipped" + ); + } + + #[test] + fn http_urls_are_targets_with_their_trailing_name() { + assert_eq!( + url_target("?file=https%3A%2F%2Fexample.com%2Fplans%2Fsite.dwg"), + LinkTarget::Url { + url: "https://example.com/plans/site.dwg".to_string(), + name: "site.dwg".to_string() + } + ); + // Unencoded URL in the query string works too (senders should + // encode, but browsers hand the raw text through). + assert_eq!( + url_target("?file=http://example.com/a.DXF"), + LinkTarget::Url { + url: "http://example.com/a.DXF".to_string(), + name: "a.DXF".to_string() + } + ); + // Scheme check is case-insensitive; Url normalizes to lowercase. + assert_eq!( + url_target("?file=HTTPS://example.com/x.dwg"), + LinkTarget::Url { + url: "https://example.com/x.dwg".to_string(), + name: "x.dwg".to_string() + } + ); + // Query and fragment never leak into the extracted name. + assert_eq!( + url_target("?file=https%3A%2F%2Fexample.com%2Fp.dwg%3Fv%3D1%23frag"), + LinkTarget::Url { + url: "https://example.com/p.dwg?v=1#frag".to_string(), + name: "p.dwg".to_string() + } + ); + // Percent-encoded path segments decode into the tab name. + assert_eq!( + url_target("?file=https%3A%2F%2Fexample.com%2Fmy%2520plan.dwg"), + LinkTarget::Url { + // The param decodes to ".../my%20plan.dwg", which the URL + // keeps encoded in its path (a raw space is invalid there). + url: "https://example.com/my%20plan.dwg".to_string(), + name: "my plan.dwg".to_string() + } + ); + } + + #[test] + fn non_http_schemes_are_refused_not_fetched() { + for bad in [ + "file:///C:/plan.dwg", + "javascript:alert(1)", + "data:text/plain,hello", + "ftp://example.com/plan.dwg", + "blob:https://example.com/uuid", + // A drive-letter colon is a valid single-letter scheme; a native + // path is meaningless on the web and must not become a fetch. + "C:\\drawings\\plan.dwg", + ] { + assert_eq!( + parse_link(&format!("?file={bad}")), + ParsedLink::Ignored(LinkRefusal::Scheme, bad.to_string()), + "{bad} must be refused as a scheme" + ); + } + // The refusal names the reason and echoes the value. + assert_eq!( + refusal_warning(LinkRefusal::Scheme, "javascript:alert(1)"), + "ocs: ignored ?file=\"javascript:alert(1)\" (only http(s) URLs are fetched)" + ); + } + + #[test] + fn http_urls_must_end_in_a_drawing_name() { + // No trailing segment, a directory URL, or a foreign extension. + for search in [ + "?file=https%3A%2F%2Fexample.com%2F", + "?file=https%3A%2F%2Fexample.com%2Fplans%2F", + "?file=https%3A%2F%2Fexample.com%2Fplan.pdf", + "?file=https%3A%2F%2Fexample.com%2Fplan.dwg.txt", + // The extension check sees through dot-doubling tricks. + "?file=https%3A%2F%2Fexample.com%2F..dwgx", + ] { + assert!( + matches!(parse_link(search), ParsedLink::Ignored(LinkRefusal::NotDrawing, _)), + "{search} must be refused as not a drawing" + ); + } + // A dot-less segment is its own extension, mirroring the drop filter. + assert!(matches!( + url_target("?file=https%3A%2F%2Fexample.com%2FDWG"), + LinkTarget::Url { name, .. } if name == "DWG" + )); + } + + #[test] + fn malformed_http_urls_are_refused() { + assert!(matches!( + parse_link("?file=http%3A%2F%2F"), + ParsedLink::Ignored(LinkRefusal::BadUrl, _) + )); + } + + #[test] + fn bare_names_target_the_recent_store() { + assert_eq!( + url_target("?file=plan.dwg"), + LinkTarget::RecentName("plan.dwg".to_string()) + ); + // Encoded spaces and non-ASCII decode into the name. + assert_eq!( + url_target("?file=my+plan.dxf"), + LinkTarget::RecentName("my plan.dxf".to_string()) + ); + assert_eq!( + url_target("?file=%C3%A7izim.dwg"), + LinkTarget::RecentName("çizim.dwg".to_string()) + ); + // No scheme and no colon, but slashes stay a name — the recent store + // keys by the hashed name, so there is nothing to traverse. + assert_eq!( + url_target("?file=sub%2Fplan.dwg"), + LinkTarget::RecentName("sub/plan.dwg".to_string()) + ); + } + + #[test] + fn percent_decode_only_touches_valid_escapes() { + assert_eq!(percent_decode("a%20b"), "a b"); + assert_eq!(percent_decode("%2Fd%2E"), "/d."); + // Truncated and invalid escapes pass through verbatim. + assert_eq!(percent_decode("100%"), "100%"); + assert_eq!(percent_decode("%zz"), "%zz"); + assert_eq!(percent_decode("%2"), "%2"); + // Non-ASCII text survives untouched; bad UTF-8 escapes go lossy. + assert_eq!(percent_decode("çizim.dwg"), "çizim.dwg"); + assert_eq!(percent_decode("%ff"), "\u{fffd}"); + } + + #[test] + fn scheme_prefix_matches_rfc3986_shape_only() { + assert_eq!(scheme_prefix("https://x"), Some("https".to_string())); + assert_eq!(scheme_prefix("c:\\x"), Some("c".to_string())); + assert_eq!( + scheme_prefix("x1+2-3.4:rest"), + Some("x1+2-3.4".to_string()) + ); + // A colon after a non-scheme character (space, slash, digit start) + // is not a scheme. + assert_eq!(scheme_prefix("plan.dwg"), None); + assert_eq!(scheme_prefix("a b:c"), None); + assert_eq!(scheme_prefix("1http://x"), None); + assert_eq!(scheme_prefix("no-colon"), None); + } +} diff --git a/src/io/web_session.rs b/src/io/web_session.rs new file mode 100644 index 000000000..673d45c16 --- /dev/null +++ b/src/io/web_session.rs @@ -0,0 +1,126 @@ +//! Web session memory: which drawings were open (and which was active) the +//! last time a save/open/close touched the tab list. +//! +//! Deliberately NOT a session-restore engine: nothing ever auto-opens from +//! this list. The next boot's crash-recovery flow reads it only to show what +//! the session had and — once restores complete — to put the focus back on +//! the tab the session remembered as active. A small `Vec` + index +//! in localStorage, rewritten in full on every save/open/close, is the whole +//! cost. + +use serde::{Deserialize, Serialize}; + +/// localStorage key, next to `opencadstudio.settings`. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) const SESSION_KEY: &str = "opencadstudio.session"; + +/// The remembered tab list. `active` indexes `tabs`; construction and +/// parsing both clamp it so a stale index can never point outside the list. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WebSession { + pub tabs: Vec, + pub active: usize, +} + +impl WebSession { + pub fn new(tabs: Vec, active: usize) -> Self { + Self { + active: active.min(tabs.len().saturating_sub(1)), + tabs, + } + } + + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native + fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string()) + } + + /// Forgiving parse: a missing or out-of-range `active` clamps instead of + /// failing (the list is advisory memory, never load-bearing). + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native + fn from_json(json: &str) -> Option { + let session: Self = serde_json::from_str(json).ok()?; + Some(Self::new(session.tabs, session.active)) + } +} + +/// Remember `tabs`/`active` for the next boot. Best-effort: unavailable or +/// read-only storage silently skips the write. +#[cfg(target_arch = "wasm32")] +pub fn persist(tabs: &[String], active: usize) { + if cfg!(test) { + return; + } + let session = WebSession::new(tabs.to_vec(), active); + if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten()) + { + let _ = storage.set_item(SESSION_KEY, &session.to_json()); + } +} + +/// What the previous session had open, if it ever recorded anything. +#[cfg(target_arch = "wasm32")] +pub fn load() -> Option { + let json = web_sys::window() + .and_then(|window| window.local_storage().ok().flatten()) + .and_then(|storage| storage.get_item(SESSION_KEY).ok().flatten())?; + WebSession::from_json(&json) +} + +// Pure logic only — the localStorage half needs a browser and is exercised +// by hand (and by every web boot's restore flow). +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_session_round_trips_through_json() { + let session = WebSession::new( + vec!["plan.dwg".to_string(), "sketch.dxf".to_string()], + 1, + ); + let restored = WebSession::from_json(&session.to_json()).expect("must parse"); + assert_eq!(restored, session); + assert_eq!(restored.tabs, ["plan.dwg", "sketch.dxf"]); + assert_eq!(restored.active, 1); + } + + #[test] + fn an_out_of_range_active_index_clamps_instead_of_failing() { + // Construction clamps… + assert_eq!(WebSession::new(vec!["a.dwg".to_string()], 9).active, 0); + // …and so does a parse of stale or hostile JSON, both directions. + let over = WebSession::from_json(r#"{"tabs":["a.dwg","b.dwg"],"active":99}"#) + .expect("must parse"); + assert_eq!(over.active, 1); + let under = WebSession::from_json(r#"{"tabs":["a.dwg"],"active":0}"#).unwrap(); + assert_eq!(under.active, 0); + } + + #[test] + fn garbage_is_rejected_and_an_empty_list_is_fine() { + assert!(WebSession::from_json("").is_none()); + assert!(WebSession::from_json("not json").is_none()); + assert!(WebSession::from_json("{}").is_none()); // missing `tabs` field + // A session whose every tab closed is still valid memory (it says + // the crash lost nothing that was visibly open). + let empty = WebSession::from_json(r#"{"tabs":[],"active":0}"#).unwrap(); + assert!(empty.tabs.is_empty()); + assert_eq!(empty.active, 0); + } + + #[test] + fn duplicate_names_are_kept_as_is() { + // Two tabs may share a display name on the web; the memory list + // records the list as it was, de-duplication is not its job. + let session = WebSession::new( + vec!["a.dwg".to_string(), "a.dwg".to_string()], + 1, + ); + assert_eq!( + WebSession::from_json(&session.to_json()).unwrap(), + session + ); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 4d0e403df..936d189c9 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2703,6 +2703,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SAVEALL: saved {saved} drawing(s){}." => Some(("file", "saveall-saved-saved-drawing-s")), "SAVEALL: {} already has a save running" => Some(("file", "saveall-already-has-a-save-running")), "SAVEALL: {} failed: {e}" => Some(("file", "saveall-failed-e")), + "Restore all" => Some(("file", "restore-all")), + "Restoring {} drawing(s)…" => Some(("file", "restoring-drawing-s")), + "Session had {count} open drawing(s)." => Some(("file", "session-had-open-drawing-s")), "At most {MAX_EXTRACTION_SOURCES} drawings can be added in the browser." => Some(("file", "extraction-max-sources-web")), "\"{}\" is already open in another tab. On the web both tabs share one stored copy — the last save overwrites the other." => Some(("file", "web-already-open-shared-copy")), "SAVETIME autosave interval in minutes (0 = off):" => Some(("styles", "savetime-autosave-interval-in-minutes-0-off")), diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index f01967d9c..1b03ba9ed 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -1278,7 +1278,9 @@ mod web_fetch { /// The actual request. `GET url` with an abort signal armed at /// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the /// declared `Content-Length` first and the received length second. - async fn fetch_once(url: &str) -> Result, RemoteFetchError> { + /// `pub(crate)` so the `?file=` deep link can fetch its drawing through + /// this exact reader (see `io::web_link`). + pub(crate) async fn fetch_once(url: &str) -> Result, RemoteFetchError> { use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; @@ -1369,6 +1371,10 @@ pub use web_fetch::{ take_outcome as take_remote_fetch_outcome, take_overflowed, RemoteFetchError, RemoteFetchOutcome, }; +// The capped streaming reader itself, shared with the `?file=` deep-link +// fetch (`io::web_link`) so both doors enforce the same 64 MB / 8 s bounds. +#[cfg(target_arch = "wasm32")] +pub(crate) use web_fetch::fetch_once as fetch_url_bytes; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { diff --git a/web-app.html b/web-app.html index 612262827..b66bb39f0 100644 --- a/web-app.html +++ b/web-app.html @@ -45,6 +45,7 @@ +
@@ -53,6 +54,21 @@
Loading…
+