open-standards/aac/aac-rs/crates/aac-web/static/sw.js
Transaction Science 9e97fc422b feat(aac-web): a symbol in every cell; OBF with licences and languages; 45-language spoken forms
Symbols (grid_cell.rs, symbol_settings.rs): every cell draws its concept's
symbol above the label (aac_core:🔣:lookup_for_cell), from the set the
person picks in Settings — Mulberry (default), OpenMoji, Blissymbolics or
off — with the label still the accessible name. "Symbols and licences" gives
each set's attribution and CC BY-SA 4.0 link, and says the symbols are free
and will never be charged for. The prerender draws the default set; a saved
choice is restored after hydration. Navigation arrows mirror on right-to-left
boards (Blissymbols never do: a mirrored Bliss character is another one);
Bliss lines stay visible on dark cells and in forced colours. Both service
workers precache every sprite the first page draws and LICENSES.md, and
serve anything under symbols/ before the app-shell rule.

The grid now fills its board area: it was sized by its content (cells about
30 px tall on desktop, and any content change moved every cell). Cells are
now about 120 px on desktop, and identical with symbols on, off or any set.

Right-to-left boards: board_area.rs passed a full locale ("ar-SA") to
language_by_code, so Arabic, Hebrew, Persian and Urdu boards never turned
right to left. Fixed, together with eye-gaze hit-testing (gaze_to_cell),
which now mirrors columns on those boards so gaze lands on the cell it looks at.

OBF (obf.rs, import_export.rs): export carries the person's locale, per-button
translations, licence objects (each symbol set's), images for each symbol
(inline SVG in the browser export, so the file is portable), and
ext_aurasense_locked / word_class / provenance / spoken; import round-trips
them. Other apps' pictures are not stored yet (a warning says so); re-importing
a board asks whether to replace it.

Spoken forms: speech_lexicon.json covers all 45 languages (empty where the
board glosses need none), checked against macOS voices with Whisper
(tools/speech/results.json); tools/speech/review.json lists gloss problems
for native speakers (Hebrew ה, Amharic ው and Zulu i glossed as "the";
Japanese nouns in kana; bound suffixes glossed as words).

tools/symbols/bbox: a small usvg tool reporting symbols' stroke bounding boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 01:47:08 -04:00

181 lines
8.4 KiB
JavaScript

// Service worker for the AAC reference app — offline-first.
//
// AAC must never fail because of network. prerender-static.sh fills in
// VERSION (a hash of everything below), PRECACHE_URLS (the app shell and
// every file it loads, including wasm-bindgen snippets, the symbol sprites
// the first frame draws, and the symbols' LICENSES.md), VENDOR_DIRS (the vendored library directories this
// build ships) and SYMBOL_FILES (every symbol sprite this build ships). A new version installs
// completely before it replaces the old one, so the shell served is always the
// one that matches its files. Content-hashed files already held from the last
// version are copied across instead of downloaded again. Only successful,
// same-origin GET responses are ever cached.
//
// The app is served cross-origin isolated (COOP + COEP), which threaded WASM
// needs for SharedArrayBuffer. Every file it loads is same-origin, including
// the eye-tracking library, which the build vendors under vendor/.
const VERSION = '__VERSION__';
const PRECACHE = 'aac-precache-' + VERSION;
const RUNTIME = 'aac-runtime';
const RUNTIME_MAX = 150;
// Vendored libraries (eye tracking, ~16 MB) are cached on first use rather
// than precached, so installing the app stays light. They live in their own
// cache so the runtime cache's trimming never evicts part of one. Each
// directory's name changes whenever its bytes do (third_party/mediapipe/
// vendor.sh), so the cache holds only this build's directories.
const VENDOR = 'aac-vendor';
const VENDOR_PATH = new URL('vendor/', self.registration.scope).pathname;
const VENDOR_DIRS = __VENDOR_DIRS__.map((dir) => VENDOR_PATH + dir);
const IN_VENDOR_DIRS = (path) => VENDOR_DIRS.some((dir) => path.startsWith(dir));
// Voice and model packs (src/bindings/model_store.js) are published under
// models/ and fetched with cache: 'no-store'. The worker leaves both to the
// network: the store verifies a pack and keeps it in its own cache, and a
// second, unverified copy here would double its size and could be served back
// in place of a corrected download.
const MODELS_PATH = new URL('models/', self.registration.scope).pathname;
const PRECACHE_URLS = __PRECACHE_URLS__;
// Symbol sprites (symbols/<set>.<hash>.svg). Those the prerendered board draws
// are precached, so symbols show offline from the first visit; another set is
// cached the first time it is drawn. They live in their own cache so the
// runtime cache's trimming never evicts one; it keeps this build's sprites and,
// until the next update, the previous build's (a tab still open on it may yet
// draw one). symbols/LICENSES.md is precached, and opening it (a navigation)
// gets the file, never the app shell.
const SYMBOLS = 'aac-symbols';
const SYMBOLS_PATH = new URL('symbols/', self.registration.scope).pathname;
const SYMBOL_URLS = __SYMBOL_FILES__.map((file) => SYMBOLS_PATH + file);
// A name like aac-web.0123456789ab.wasm never changes content.
const IMMUTABLE = (path) => /\.[0-9a-f]{12}\.[a-z0-9]+$/.test(path);
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(PRECACHE);
await Promise.all(PRECACHE_URLS.map(async (url) => {
if (IMMUTABLE(url)) {
const held = await caches.match(url);
if (held) return cache.put(url, held);
}
const res = await fetch(new Request(url, { cache: 'reload' }));
if (!res.ok) throw new Error('precache ' + url + ' ' + res.status);
await cache.put(url, res);
}));
await self.skipWaiting();
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keep = new Set([PRECACHE, RUNTIME, VENDOR, SYMBOLS]);
// A tab still open on the previous version keeps running its shell, and
// may yet draw a sprite it has not drawn (another set, another board):
// carry that version's precached sprites over until the next update.
const symbols = await caches.open(SYMBOLS);
const carried = new Set();
for (const key of await caches.keys()) {
if (!key.startsWith('aac-precache-') || key === PRECACHE) continue;
const old = await caches.open(key);
for (const req of await old.keys()) {
const path = new URL(req.url).pathname;
if (path.startsWith(SYMBOLS_PATH) && path.endsWith('.svg') && !SYMBOL_URLS.includes(path)) {
await symbols.put(path, await old.match(req));
carried.add(path);
}
}
}
// Also removes the cache of the first release ('aac-v1'). Downloaded voice
// and model packs ('aac-models-*') belong to the person, not to a release:
// they were fetched on request and must survive every update.
for (const key of await caches.keys()) {
if (key.startsWith('aac-') && !key.startsWith('aac-models-') && !keep.has(key)) await caches.delete(key);
}
// Vendored files from earlier builds (their directory has another name).
const vendor = await caches.open(VENDOR);
for (const req of await vendor.keys()) {
if (!IN_VENDOR_DIRS(new URL(req.url).pathname)) await vendor.delete(req);
}
// Sprites from older builds (their name has another hash).
for (const req of await symbols.keys()) {
const path = new URL(req.url).pathname;
if (!SYMBOL_URLS.includes(path) && !carried.has(path)) await symbols.delete(req);
}
await self.clients.claim();
})());
});
// Cross-origin isolation for the app shell. The server sends these headers,
// but a shell cached before it did would keep the app un-isolated until the
// app's content next changed, so the worker states them on every shell it
// serves. Redirects and errors pass through untouched.
function isolated(res) {
if (res.type === 'opaqueredirect' || !res.ok) return res;
const headers = new Headers(res.headers);
headers.set('Cross-Origin-Opener-Policy', 'same-origin');
headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}
async function trim(cache) {
const keys = await cache.keys();
for (let i = 0; i < keys.length - RUNTIME_MAX; i++) await cache.delete(keys[i]);
}
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// Other origins, any API and model packs are left to the network.
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
if (req.cache === 'no-store' || url.pathname.startsWith(MODELS_PATH)) return;
// Symbols: sprites (drawn by <use>) and LICENSES.md (opened from Settings,
// a navigation, so this comes before the shell below). A current sprite not
// yet held is cached the first time it is drawn.
if (url.pathname.startsWith(SYMBOLS_PATH)) {
event.respondWith((async () => {
const hit = await caches.match(url.pathname);
if (hit) return hit;
const res = await fetch(req);
const html = (res.headers.get('Content-Type') || '').startsWith('text/html');
if (res.ok && res.type === 'basic' && !html && SYMBOL_URLS.includes(url.pathname)) {
const copy = res.clone();
event.waitUntil(caches.open(SYMBOLS).then((cache) => cache.put(url.pathname, copy)));
}
return res;
})());
return;
}
// The app is one screen: every other navigation gets the precached shell.
if (req.mode === 'navigate') {
event.respondWith(caches.match('/').then((hit) => hit || fetch(req)).then(isolated));
return;
}
if (url.pathname.startsWith(VENDOR_PATH)) {
event.respondWith((async () => {
const hit = await caches.match(req);
if (hit) return hit;
const res = await fetch(req);
// Never an HTML page: a server that answers unknown paths with its
// index would otherwise leave that page here in place of a library file.
const html = (res.headers.get('Content-Type') || '').startsWith('text/html');
if (res.ok && res.type === 'basic' && !html && IN_VENDOR_DIRS(url.pathname)) {
const copy = res.clone();
event.waitUntil(caches.open(VENDOR).then((cache) => cache.put(req, copy)));
}
return res;
})());
return;
}
event.respondWith((async () => {
const hit = await caches.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok && res.type === 'basic') {
const cache = await caches.open(RUNTIME);
cache.put(req, res.clone()).then(() => trim(cache));
}
return res;
})());
});