web: make the studio installable, and give it a shell that opens offline
The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.
The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.
The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.
node scripts/make-icons.mjs 192x192 39785B / 512x512 159296B / maskable 512x512 123723B
node scripts/pwa-check.mjs manifest 3 icons · worker activated · shell cached
· offline reload of /app paints
off (https://localhost:8090) same four, through nginx
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "RecipesCam — Color Recipes & Film Camera Studio",
|
||||
"short_name": "RecipesCam",
|
||||
"description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
|
||||
"lang": "vi",
|
||||
"start_url": "/app",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Thư viện",
|
||||
"short_name": "Thư viện",
|
||||
"url": "/library"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// The app shell as a service worker, so an installed RecipesCam opens with no
|
||||
// network at all. Not bundled on purpose: through vite/rollup it would stop being
|
||||
// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
|
||||
// name and the whole update story — bump it when the shell changes, and `activate`
|
||||
// drops every older cache.
|
||||
const VERSION = 'recipescam-v1';
|
||||
|
||||
// nginx answers all three with the same index.html (SPA fallback), so they are one
|
||||
// document under three keys: an offline navigation finds it whichever key it asks.
|
||||
const SHELL = ['/', '/app', '/library'];
|
||||
|
||||
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
|
||||
// the models and the icons never change under a given build.
|
||||
const STATIC = /^\/(assets|wasm|models|icons)\//;
|
||||
|
||||
// Only a complete same-origin 200 is worth keeping — an opaque or error body
|
||||
// stored here comes back as a failure on the next visit, and nothing in the worker
|
||||
// can tell the two apart by then.
|
||||
const cachedFetch = (request) =>
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(VERSION).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
// Offline, the studio the app is for; the landing page for a build without /app.
|
||||
const offlineShell = async () =>
|
||||
(await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Best effort, one route at a time: a deploy caught mid-flight must not leave the
|
||||
// worker uninstalled, so each route fails on its own.
|
||||
event.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {}))))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
|
||||
// request that is not a plain same-origin read — goes straight out.
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// A navigation is network-first even when it is cached: a shell answered from the
|
||||
// cache while the network holds a newer build pins the visitor to the old one. It
|
||||
// is also the path an installed app opens through with no network.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(fetch(request).catch(offlineShell));
|
||||
return;
|
||||
}
|
||||
|
||||
// ponytail: a navigation is never written back, the precached entry is what
|
||||
// answers offline. Add a put() here once a route must survive on its own.
|
||||
if (STATIC.test(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else — a font, the manifest, an uploaded photo — is network-first,
|
||||
// and answered from the cache only when that fails.
|
||||
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
|
||||
});
|
||||
Reference in New Issue
Block a user