--- name: pwa-configuration description: Configure Alex's VPS web apps as installable PWAs without stale-cache deploy bugs. Covers manifest, icons, service worker, iOS metadata, nginx/auth pitfalls, verification, and launcher icon reuse. version: 1.0.0 author: Hermes Agent license: MIT metadata: hermes: tags: [pwa, mobile, vite, service-worker, app-icons, nginx, vps] related_skills: [vps-app-deployment] --- # PWA Configuration Use this when Alex asks to make an app installable, mobile-home-screen friendly, standalone, or PWA-enabled. Also use during new VPS app builds that should behave like Jungle Studio Mobile, Video Story, HD Prism, or Hermes Workspace. ## Supporting references - `references/hermes-workspace-pwa.md` — session-specific retrofit notes for Hermes Workspace, including the no-cache SW implementation, targeted test command, fork/PR workflow, and nginx Basic Auth blocker. - `references/static-launcher-pwa.md` — notes from converting the nginx-served app launcher into a PWA, including icon assets, static-route verification, and the sudo/nginx header blocker. - `references/launcher-touch-gestures.md` — movement-safe long-press inspection and pull-to-refresh pattern for the launcher/PWA icon grid. ## Core rules 1. **Do not put nginx Basic Auth in front of a PWA app shell.** iOS/Android standalone launches do not reliably carry browser Basic Auth credentials and can white-screen before the app loads. Keep the HTML/JS/CSS/manifest/icon routes public and use app-level auth/session/bearer protection for sensitive screens and APIs. If the app still returns `401` at `/` without browser Basic credentials, the PWA conversion is incomplete even if the manifest and service worker are valid. 2. **Do not trust browser-tab localStorage/onboarding state to exist in an installed PWA.** iOS standalone storage can be isolated from Safari. If a PWA uses localStorage flags such as `onboarding-complete`, add a boot-time authenticated backend probe (with `credentials: 'same-origin'` and `cache: 'no-store'`) that can mark the app ready when the backend is healthy, rather than stranding the installed app in onboarding/setup. 3. **Use a no-stale-cache service worker unless offline behavior is explicitly requested.** Alex values seeing new deploys immediately. Prefer a network-only/no-cache SW that enables installability but does not cache bundles. Exception: for app launchers/icon grids with many small repeated images, cache only immutable optimized icon/thumb assets (for example `/icons/optimized/*`) while keeping HTML, JS, CSS, `apps.json`, `status.json`, `manifest.json`, and `sw.js` network-fresh/no-cache. 3. **Serve `sw.js` and `manifest.json` with no-cache headers** for both Express/static servers and nginx-only static sites. Put Express routes before static middleware; for nginx-only apps, add exact-match `location = /sw.js` and `location = /manifest.json` blocks before the catch-all/root location. 4. **Use one branded app icon everywhere**: source icon in the app repo, manifest icons, `apple-touch-icon`, favicon/shortcut icon, and the `launcher.apps.poofc.com` card. 5. **Mobile-first verification matters**: verify HTTPS, manifest JSON, icons, service worker registration, and standalone-safe layout on a real phone/incognito when feasible. 6. **Treat sudo availability as a runtime fact, not a memory fact.** If nginx header edits are needed, run `sudo -n true` first. If it still says a password is required, ask Alex for the actual password or a local credential location before attempting `/etc/nginx` edits; do not infer the password from “you have sudo password,” memory, or old notes. ## App icon workflow 1. Use the configured **GPT Image** workflow to create a square 1024×1024 branded source icon. A design/image subagent may be used to write or refine the icon prompt, but the final art should come from GPT Image by default. For new VPS apps, this is not optional: the icon must be reusable by the app PWA, `apple-touch-icon`, favicon, and the central launcher grid. Do not use quick Pillow/programmatic placeholder tiles as the final icon unless Alex explicitly asks for a temporary placeholder or GPT Image is unavailable and the result is clearly labeled temporary. The icon should read like a single iOS home-screen icon: one rounded-square composition, no icon-inside-icon, no stacked/double tile, no labels/text, and no status dots. 2. Save it in the app repo, usually `public/app-icon.png`. 3. Generate at least: - `public/icon-512.png` - `public/icon-192.png` - `public/apple-touch-icon.png` (180×180) - optional `public/favicon.png` / `favicon.ico` 4. For icon grids/launchers, do **not** serve dozens of 512px PNGs directly into 78–92px tiles. Generate responsive WebP thumbnails (for example 96px and 192px) under a stable path such as `icons/optimized/-96.webp` and `icons/optimized/-192.webp`; use `srcset`, `sizes`, `loading="lazy"`, `decoding="async"`, and width/height attributes. Keep the original 512px PNG as fallback or inspection-sheet detail. 5. Use mask-safe padding so the icon works with Android adaptive masks. 6. Update the launcher card/grid to use the same icon if the launcher supports image icons; otherwise use a close emoji only as a temporary fallback. For Alex's canonical launcher, the expected shape is a phone-style icon grid: add/update `/home/avalon/apps/app-launcher/icons/.png` and the launcher metadata (`apps.json` or its generated inventory source), not just a text link. Example resize command: ```bash python3 - <<'PY' from PIL import Image src = Image.open('public/app-icon.png').convert('RGBA') for size, out in [(512,'public/icon-512.png'), (192,'public/icon-192.png'), (180,'public/apple-touch-icon.png')]: src.resize((size, size), Image.LANCZOS).save(out) PY ``` Example launcher/grid thumbnail generator: ```bash python3 - <<'PY' from pathlib import Path from PIL import Image root = Path('icons') out = root / 'optimized' out.mkdir(exist_ok=True) for src in sorted(root.glob('*.png')): im = Image.open(src).convert('RGBA') for size, quality in [(96, 78), (192, 82)]: dest = out / f'{src.stem}-{size}.webp' im.resize((size, size), Image.Resampling.LANCZOS).save(dest, 'WEBP', quality=quality, method=6) PY ``` Use in HTML: ```html ``` ## Minimal Vite/React PWA files `public/manifest.json`: ```json { "name": "App Name", "short_name": "App", "description": "Installable app description.", "id": "/?app=app-name", "start_url": "/?source=pwa", "scope": "/", "display": "standalone", "display_override": ["window-controls-overlay", "standalone", "browser"], "orientation": "any", "background_color": "#0A0E1A", "theme_color": "#0A0E1A", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] } ``` `public/sw.js` network-only pattern: ```js self.addEventListener('install', () => self.skipWaiting()) self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((names) => Promise.all(names.map((name) => caches.delete(name)))) .then(() => self.clients.claim()), ) }) self.addEventListener('fetch', () => { // Intentionally do not call event.respondWith(); all requests stay network/server controlled. }) ``` Launcher/icon-grid variant — cache only optimized thumbnails, not the shell/data: ```js const ICON_CACHE = 'app-icons-v1' self.addEventListener('install', () => self.skipWaiting()) self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((names) => Promise.all(names.filter((name) => name !== ICON_CACHE).map((name) => caches.delete(name)))) .then(() => self.clients.claim()), ) }) self.addEventListener('fetch', (event) => { const url = new URL(event.request.url) if (event.request.method === 'GET' && url.origin === self.location.origin && url.pathname.startsWith('/icons/optimized/')) { event.respondWith( caches.open(ICON_CACHE).then(async (cache) => { const cached = await cache.match(event.request) if (cached) return cached const response = await fetch(event.request) if (response.ok) cache.put(event.request, response.clone()) return response }), ) } }) ``` When registering the SW, preserve the icon cache if the page still clears old caches: ```js if (window.caches?.keys) { caches.keys() .then((names) => names.filter((name) => name !== 'app-icons-v1').forEach((name) => caches.delete(name))) .catch(() => {}) } navigator.serviceWorker.register('/sw.js', { scope: '/' }) .then((registration) => registration.update().catch(() => {})) .catch(console.warn) ``` HTML/head requirements: ```html ``` Client registration: ```js if ('serviceWorker' in navigator) { window.addEventListener('load', () => { caches?.keys?.().then((names) => names.forEach((name) => caches.delete(name))).catch(() => {}) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(console.warn) }) } ``` ## Express no-cache routes Add before `express.static`: ```js app.get('/sw.js', (_req, res) => { res.set({ 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Service-Worker-Allowed': '/', }) res.sendFile(path.join(distOrPublicDir, 'sw.js')) }) app.get('/manifest.json', (_req, res) => { res.set({ 'Content-Type': 'application/manifest+json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate', }) res.sendFile(path.join(distOrPublicDir, 'manifest.json')) }) ``` For Nginx-only/static apps, add exact-match location blocks **before** the generic `location /` block. Prefer `alias` for exact file locations instead of `root` + `try_files`; the `root`/`try_files` form can internally redirect into the generic static location and silently drop the intended headers. Example for the launcher-style static root: ```nginx location = /manifest.json { alias /home/avalon/apps/APP/manifest.json; default_type application/manifest+json; add_header Cache-Control "no-cache, no-store, must-revalidate" always; } location = /sw.js { alias /home/avalon/apps/APP/sw.js; default_type application/javascript; add_header Cache-Control "no-cache, no-store, must-revalidate" always; add_header Service-Worker-Allowed "/" always; } location / { root /home/avalon/apps/APP; index index.html; try_files $uri $uri/ /index.html; } ``` After editing nginx, always run `sudo nginx -t` before `sudo systemctl reload nginx`. If sudo is not currently usable, still complete the repo/static-file PWA work, verify the routes return 200, and report the exact remaining root-only header change. ## Layout and touch-interaction pitfalls - iOS standalone viewports differ from Safari. Use `min-height: 100dvh`/`h-dvh` plus safe-area padding. - Bottom nav/tab bars should be `position: fixed; bottom: 0; left: 0; right: 0;` with `padding-bottom: env(safe-area-inset-bottom)` or equivalent Tailwind classes. - Avoid boot-time storage/theme code that can throw before React mounts; wrap localStorage/theme setup in try/catch. - For internal setup/checklist wizards, localStorage can be useful for non-secret durable progress, but never use it as a secret store. Keep API tokens/keys out of browser state; use the wizard to generate a safe `.env` template and tell the operator to place real secrets only in uncommitted server-side env files. - For Vite/React PWA refactors on current TypeScript, prefer `"moduleResolution": "Bundler"` in `tsconfig.json`; `"Node"`/`node10` can now fail under TS 6 deprecation checks. Install `@types/react` and `@types/react-dom`, and add `src/vite-env.d.ts` for CSS/module imports before treating `tsc --noEmit` failures as app logic bugs. - Do not place `NODE_ENV=production` in Vite `.env` files; Vite warns that production NODE_ENV is unsupported there. Keep `NODE_ENV=production` in PM2/start environment and reserve Vite `.env` for actual app configuration/secrets. - Multi-step PWA onboarding flows should not encode success/error screens as out-of-range indices into the normal `steps` array unless all render paths are bounds-checked. A successful async submit can otherwise crash only at the final transition in minified production builds (for example `steps[step].validate()` when `step === steps.length`), presenting to users as a blank screen or instant reset. Prefer explicit `status: 'form' | 'submitting' | 'success' | 'error'`, or clamp `currentStep` and disable form validation outside form steps. - Add a lightweight React error boundary around the app root for PWAs/admin shells so startup/runtime exceptions show a visible recovery message instead of a white screen. - Do not force reload on first SW controller claim; it can create first-launch white screens. - For icon grids or app launchers, long-press/context inspection must be movement-safe. Start the timer on `pointerdown`, but cancel it on `pointermove` beyond a small threshold (about 10px), `pointercancel`, `pointerleave`, and page `scroll`; otherwise normal thumb scrolling opens endless inspect sheets on mobile. Use a longer threshold than an accidental touch (about 650ms) and only open the app on `pointerup` when no movement/inspection occurred. - Pull-to-refresh in a PWA should work from the top of the page only (`window.scrollY === 0`), use non-passive `touchmove` only while actively pulling, and provide a visible state (`Pull to refresh` → `Release to refresh` → `Refreshing…`). For no-cache/static PWAs, refresh should update the service worker registration, refetch dynamic JSON/status data, then reload the page so the latest HTML/JS is definitely used. ## Testing command pitfall When a package script is shaped like `"test": "vitest run"`, running `pnpm test -- path/to/test.ts` can still execute the whole suite in some projects/toolchains. For targeted PWA guard tests, prefer direct Vitest invocation: ```bash pnpm exec vitest run src/routes/-root-runtime-guards.test.ts ``` ## Verification checklist ```bash curl -I https://APP.apps.poofc.com/manifest.json curl -I https://APP.apps.poofc.com/sw.js curl -s https://APP.apps.poofc.com/manifest.json | jq . curl -I https://APP.apps.poofc.com/icon-512.png ``` Then verify: - Manifest has `display: standalone`, `start_url`, `scope`, `theme_color`, and 192/512 icons. - `sw.js` returns 200 with no-cache headers. - App shell loads without nginx Basic Auth if installed standalone. - PWA install prompt/Add to Home Screen works. - After a redeploy, a hard refresh or relaunch sees new JS/CSS rather than stale bundles. - `launcher.apps.poofc.com` includes/updates the app card using the branded icon/link.