--- 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. - `references/hermes-mobile-pwa-bff.md` — mobile admin PWA/BFF pattern for using local-only backends such as Hermes dashboard/gateway from iPhone without publicly exposing the raw service port. - `references/hermes-mobile-update-and-icon-lessons.md` — Hermes Mobile lessons for stale installed-PWA updates, generated app icons, and safely absorbing App Launcher data before retiring it. - `references/hermes-mobile-batch-icon-regeneration.md` — batch workflow for regenerating many Hermes Mobile launcher icons, using cache-busting filenames, backups, contact-sheet review, and public URL verification. - `references/mobile-pwa-session-auth.md` — authenticated PWA session pattern: HTTP-only cookies plus persistent server-side session store so login survives PM2 restarts without client-side password storage. ## 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 store passwords in the browser; persist PWA login with server-side sessions.** For password-gated internal PWAs, the client should get only an opaque HTTP-only cookie. If using Express, do not leave `express-session` on the default MemoryStore when Alex expects "enter password once" behavior: PM2 restarts/deploys erase in-memory sessions and cause protected API calls to return `401 unauthorized` while the mounted React UI still appears logged in. Use SQLite/Redis/Postgres or a small JSON-file store for single-user internal tools, with `HttpOnly`, `SameSite=Lax`, `Secure` cookies and a rolling/appropriate max age. See `references/mobile-pwa-session-auth.md`. 3. **For mobile admin PWAs over local services, use a BFF and lock the raw service to loopback.** If the app needs Hermes dashboard/gateway, local CLIs, filesystem artifacts, cron controls, or other privileged internals, put an Express/Node Backend-for-Frontend behind the PWA origin. The browser should only call same-origin `/api/*` routes with session auth; the BFF calls `127.0.0.1` services or CLIs server-side. After any temporary remote-desktop/backend test, rebind the raw service to `127.0.0.1` and verify the public IP:port no longer responds. 3. **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. If Alex says an installed PWA is not showing changes, treat this as a PWA freshness bug first: verify `/`/fallback HTML has no-cache headers, register `sw.js` with a build/version query, call `registration.update()`, delete old caches on activate, and add a visible build/version marker while debugging. 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 SVG, Pillow/programmatic, emoji-tile, or other 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. If a session previously shipped a placeholder icon, replace it with an image-generated icon before calling the app polished. 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. - When Alex wants apps to look like an iPhone home screen, render each app as just the rounded-square icon plus label below, not a card with description/status. Use a fixed square frame (about 64–76px mobile, 76–92px desktop), `display:grid; place-items:center; overflow:hidden`, and image `width:100%; height:100%; object-fit:cover; object-position:center` so generated icons read centered even when the source image has uneven padding. Labels should be short, centered, and clamped to 1–2 lines. - If many app icons show black edges, off-center subjects, or a smaller rounded tile inside the icon, treat it as a **source-icon problem**, not just CSS. A display-side `object-fit: cover` plus mild scale can help only mildly padded sources; for baked-in icon-inside-icon artifacts, regenerate full-bleed source icons. - For installed PWAs, prefer cache-busting icon filenames when changing launcher icons that Alex needs to see immediately: write `icons/-regen.png` (or similar), update metadata to point at it, and keep a backup such as `.original-before-regenerated-batch.png`. Replacing the old `.png` alone can be hidden by static/PWA cache. - After a batch regeneration, create a contact sheet for visual review and HEAD-request every new public icon URL before calling the update complete. See `references/hermes-mobile-batch-icon-regeneration.md` for the Hermes Mobile batch pattern. 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. - If the app uses `viewport-fit=cover` + `apple-mobile-web-app-status-bar-style=black-translucent`, the app **must** reserve the top safe area on the root shell. Do not later override it away with `.app{padding-top:0}` or a sticky header at `top:0`; this makes the OS status bar cover controls. Durable shell pattern: define `--safe-top: env(safe-area-inset-top,0px)` and set `.app{padding-top:max(12px,var(--safe-top))}` while keeping sticky headers inside that padded shell. - For Next/React apps where the header itself is the sticky top element (for example an `AppHeader` rendered directly under the root layout), apply the safe-area class/rule to **every header render branch** (logged-in and logged-out). A sidebar may already be safe-area padded while the hamburger remains hidden if the sticky app header lacks it. Prefer a reusable rule such as `.pwa-safe-top { padding-top: max(env(safe-area-inset-top, 0px), 0px); }` and add it to the sticky header class list. - Verification for this class of bug should include live deployed HTML/CSS, not only source/build: fetch the public page, confirm the header class is present in rendered HTML, fetch discovered CSS assets, and confirm the safe-area rule contains `safe-area-inset-top`. Browser desktop will report `env(safe-area-inset-top)` as `0px`, so a zero computed padding in desktop automation is not evidence the PWA fix is absent. - If the PWA uses a sidebar/drawer and `viewport-fit=cover` + translucent iOS status bar, put the drawer close button inside a container padded with `max(base, calc(env(safe-area-inset-top, 0px) + base))`; do not rely on ordinary `padding: 16px` at the top of fixed sidebars because iPhone standalone mode can place the status bar over the close control. - Bottom nav/tab bars should be anchored to the physical bottom, but do **not** blindly stack multiple safe-area treatments. A common iPhone failure is: `h-dvh` flex shell + fixed/flex tab bar + separate bottom spacer + `padding-bottom: env(safe-area-inset-bottom)`, which creates a large white gap above the home indicator. Another failure is a floating pill nav (`left/right:8px; bottom:calc(8px + env(...))`) when the product wants app-like chrome. Use one strategy only: either the tab bar owns the inset or the shell owns it. For docked app chrome, use `position:fixed; left:0; right:0; bottom:0; width:100vw; padding-bottom:calc(base + env(safe-area-inset-bottom))`, remove side/bottom offsets, and add matching root content padding so scrollable content is not hidden under the tab bar. - For Jungle-style mobile dashboards, keep decorative serif fonts for human-readable titles/card names, but use the less-decorative UI font (Termina) for numeric metrics such as stats, projected earnings, booked counts, and formulas; prefer normal/lighter weights when Alex says the numbers feel too heavy. - 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`, `background_color`, and 192/512 icons. - When changing PWA/app chrome colors, verify **both** HTML `` and `manifest.json` `theme_color`/`background_color`; it is easy to update the HTML shell while leaving install/splash chrome on the old color. - `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.