From b19409aacb6769e5c9e15db97626fff69e02ed52 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 12:04:26 +0200 Subject: [PATCH] feat: the editor installs and runs with the network gone Nothing here talks to a server at runtime, so a cached shell is the whole editor. The worker reads the build's hashed asset names out of the entry document, which precaches without a build step; the document itself stays network-first, or it would keep naming the assets of the build it was cached with. Matching ignores Vary: a precached asset is fetched without the page's Origin, and a server answering "Vary: Origin" never matches it. Verified in a headless Chromium with the network cut. --- CHANGELOG.md | 3 +- index.html | 4 +++ public/icon-192.png | Bin 0 -> 1621 bytes public/icon-512.png | Bin 0 -> 4527 bytes public/manifest.webmanifest | 19 ++++++++++ public/sw.js | 68 ++++++++++++++++++++++++++++++++++++ src/main.ts | 8 +++++ 7 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da46b6..9cd4731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,7 @@ - Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field - Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents - Document properties (title, subject, author, keywords, comments) in the File menu — LibreOffice's File ▸ Properties / Word's File ▸ Info. Round-trips through ODF `meta.xml` (one `meta:keyword` per keyword) and DOCX `docProps/core.xml` +- Installable and offline (`public/manifest.webmanifest`, `public/sw.js`): nothing here talks to a server at runtime, so once the app's files are cached it is the whole working editor with the network gone — spell checker, fonts and all. The service worker reads the build's own hashed asset names out of the entry document, which precaches the shell with no build step; what the app loads lazily (a dictionary, the speller's WASM) is kept as it is asked for, so it joins from the second visit on. The document itself is network-first, or a cached one would keep naming the assets of the build it was cached with. Verified in a headless Chromium with the network cut: the app reloads, styles and all, and the manifest validates clean - PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog) - Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact) @@ -160,7 +161,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) - Password-protected ODT/DOCX; digital signatures - Navigator / outline view, split view -- PWA / offline support +- File handlers, so the OS can offer the installed app for a double-clicked `.odt`/`.docx` (Chrome's `file_handlers` + `launchQueue`) - A committed corpus of documents we author ourselves, so CI can test against real files (`.gitignore` excludes all of `tests/render-parity/fixtures/` today) diff --git a/index.html b/index.html index b1199e5..81930f2 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,10 @@ EdenText + + + +
diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4a27835cd7a8a14955bb154c4b84bf8a1a4bc154 GIT binary patch literal 1621 zcmb_d`#;kQ7#?5E$WCjdS|$on$!(fs+8A0%E7MsbtTLAsX{U+IX6qoigpb^ol}m2N zC6#hpt>jiHnJkyW5y_nl!yjd7o&alLJ&4p$r0npoF9N)3W(; zy&&?k-dnd!1cAWH1U!xuocXh-EskWTQ9o_dPVmNmf}Oy->%W209FIm#xTStojf$t- zR7TgamlP65gd6TAq&|PZu-LfW@l8~?u*@{aHz?_Wtr@0rjMQ6bIlsa!^Q<2jk}_Qu z`m9Z|+5p(t3lIbX(@}uHi)RI${Ui3O(q{gC_2_O7<%D$@{X7LG*Gw@#P$JVm z9xkTsKOP0jWsr&MQ}ALZmZUAC!}XHxAyj=PF!#Q53>`3TxXLBaEd&t^)|I|0q-Mj&Bjf04hLw}^yMtKU z6>ft``a^C_KjK_=jBdzdIHFB2@L7YTR1gYJS3>x7+`TyK__~6vK08(r-o$$lg$*vo z-IsGM$-;>Y)Y0))#~O{XMf0k8uDNI%z+BHmsostaF8uUE+H7d44Avb|=Tlotad`%! z?;GaWb((p@sg!0N)928jYQ=OW^RL!Iw&-0cF3uqDjxh(5zQ86?Ag&${isez>%v^Vl zWk26G5}JISJ8G6cZ!oON-@TO&xVRI{gkrPhrzKRxiCLv~2_T)yqrf*+?exMhMM;6& z#jwH}gCsCQ0D_V^r@y@x24{ZboB%FYiAa(ZdNxg-{wk`KaK3?xNk9pJs|cf_F>v9Yo8x^1Q=yh` zDAz=AJDaUFiow!Tqree4PnzHH0W+eoY?d^L18hR@gqizCF2y}4vglSPp*%FPTZ;UuCNlapG7OLiF?fGWggd(Z*0lh|SnDuXkDP5@aQoC(5a)9W zw}(%9HP4b(&K6stsO;lFg0`x zOG!A%DsfviMhQn3{o|qZe`gf6Io3N{mV1EM9@>n%cAN(%^KYID5Ub=M+L3*?DR4W7E|6xJbqFI(za2H9rPyX=;lic8VNwB)iQ7N$KZUqEeiyw9r2w z@kFY^nFNx{h4yQ%Xa0WA{j zC#G=!aVl>b?)sW`ww$KL^xPZMX)qjOIjhqC1A*$6B8pR>a(+$D&a{C=bMk%4+jt9_ z1KEXYGMJ@PiMeHnZhtfDU#5W=92+eJ=Gp{ny^_Uyu(5ZeNrJV%)&@cY+*ZCd5QU?E z_V+9;x2{y&OI~}K8#5`GnJFL^iFb^M=XYp}L~9K;7R<@`TK|PBk?`}O!86$~=9g-- z>cgAL)#a48T&p|UbA!p8Vr&lA4ELR;{t~8rZIS9N@til=o~khXKBuq@+ESeL>LEMA z{K`Y^f?D>o%1U7Gb(*!2+l&ulx5{sPr$d`%nXtCjh41&30q}Y%03f7)x8i;1zHKrF NM6h$hm)Lkl{0G8e&)fh2 literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..93306193a76f06bd41523c76100cecb1e16b2fe9 GIT binary patch literal 4527 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 m[1]); + await cache.addAll([...new Set(assets), '/favicon.svg', '/manifest.webmanifest']); +} + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then(precache).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +// The document is network-first: served from the cache it would keep naming the assets +// of the build it was cached with, and a new one would never be fetched. +async function documentFrom(request) { + try { + const response = await fetch(request); + if (response.ok) (await caches.open(CACHE)).put(SHELL, response.clone()); + return response; + } catch { + const cached = await caches.match(SHELL, { ignoreVary: true }); + if (cached) return cached; + throw new Error('offline and no cached shell'); + } +} + +// Everything else is cache-first: the build's own files carry a content hash, so a +// cached one can never be stale. +// ponytail: the cache is never version-swept, so a redeploy's old assets stay in it +// until the browser evicts them. Sweeping needs the build id, which needs a build step. +async function assetFrom(request) { + // ignoreVary: a precached asset was fetched without the page's `Origin`, and a + // static server that answers `Vary: Origin` would make the two never match. + const cached = await caches.match(request, { ignoreVary: true }); + if (cached) return cached; + const response = await fetch(request); + // An opaque response hides its status, so only a real same-origin one is kept. + if (response.ok && response.type === 'basic') (await caches.open(CACHE)).put(request, response.clone()); + return response; +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + if (new URL(request.url).origin !== self.location.origin) return; + event.respondWith(request.mode === 'navigate' ? documentFrom(request) : assetFrom(request)); +}); diff --git a/src/main.ts b/src/main.ts index b01bddf..f2fd437 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,14 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () if (loadTheme() === 'auto') applyTheme('auto'); }); +// Offline support (public/sw.js). Not in dev, where a worker would serve the cached +// build over the one being edited. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} + const app = mount(App, { target: document.getElementById('app')! }); export default app;