hardware/mac-studio/services/nginx-pwa-injection.md
Table of Contents
Nginx PWA Injection Pattern
How self-hosted dev tools (OpenCode, Reasonix, ttyd, pi-webui, etc.) get turned into installable, home-screen PWAs with custom UX tweaks — without touching their source code. Everything happens at the nginx layer: static assets served from ~/Work/pwa/<app>/, injected into each proxied app's <head> via sub_filter.
This is a distinct concern from the auth layer (nginx-auth-service.md) and the nginx server itself (nginx.md), but all three live in the same sites-enabled/*.conf files — this doc is the reference for the PWA/injection half specifically.
Why this exists
None of these upstream apps (OpenCode, Reasonix, pi-webui, ttyd) ship a PWA manifest, custom icon, or mobile-friendly toolbar out of the box. Rather than forking/patching each one, nginx:
- Serves PWA metadata (
manifest.json, icons, optionally a service worker) from a static directory, so "Add to Home Screen" works and installs a proper app icon/name instead of a bare Safari tab. - Rewrites the proxied HTML on the fly —
sub_filter '<head>' '<head><link ...><script ...>'— to inject<link rel="manifest">,<link rel="stylesheet">, and<script>tags into every page the backend serves, without the backend knowing anything happened.
File layout: ~/Work/pwa/<app>/
~/Work/pwa/
├── opencode/ manifest.json, icon-192.png, icon-512.png, icon-512-maskable.png,
│ script.js, style.css, sw.js (full set incl. service worker)
├── pi/ manifest.json, icon.svg, script.js
├── reasonix/ manifest.json, icon.svg, icon.png, script.js, style.css
├── ttyd/ manifest.json, icon.svg, script.js, style.css, README.md
├── pi-web/ manifest.json, icon.svg (metadata only)
├── pi-win/ manifest.json, icon.svg (metadata only)
├── pi-wsl/ manifest.json, icon.svg (metadata only)
├── dash/ manifest.json, icon.png (metadata only)
└── dsh/ manifest.json, icon.svg (metadata only)
Every subdirectory maps 1:1 to a *.dph.am site (c-de.dph.am reuses opencode/, since it's the same backend app). Not every app has the full set — some just get a manifest/icon for installability with no behavioral changes (pi-web, pi-win, pi-wsl, dash, dsh); others (opencode, pi, reasonix, ttyd) also ship script.js/style.css for real UX fixes.
How nginx wires it up
Two patterns are in use across sites-enabled/:
Pattern A — explicit per-file locations
Used when the app's own routing might otherwise swallow the path (SPA catch-alls). Each asset gets its own exact-match location block, served directly off disk with alias:
# pi.dph.am.conf
location = /manifest.json { alias /Users/dominick/Work/pwa/pi/manifest.json; }
location = /icon.svg { alias /Users/dominick/Work/pwa/pi/icon.svg; }
location = /script.js { alias /Users/dominick/Work/pwa/pi/script.js; }
Used by 0.dph.am (as /manifest.json + /_static/*), pi.dph.am, rsx.dph.am, term.dph.am.
Pattern B — single /_static/ bucket
Simpler: one location /_static/ maps the whole app subdirectory via alias, and everything is referenced under that prefix:
# dash.dph.am.conf, dsh.dph.am.conf, pi-web.dph.am.conf, c-de.dph.am.conf
location /_static/ { alias /Users/dominick/Work/pwa/dash/; }
2026-09-20 update: these used to proxy_pass http://192.168.1.189/pwa/<app>/... — nginx proxying to itself over the LAN interface, landing on the default_server block (_localhost.conf, root /Users/dominick/Work) to resolve the file. That made sense back when nginx and the PWA files could plausibly have lived on different machines, but now that they're guaranteed to be co-located, it was a pointless network round-trip through a second, unrelated routing path — and that indirection is exactly the kind of thing that caused the earlier 403 bug (worker-user permissions on the fallback root). Switched every asset location to alias, reading straight off disk with no extra hop and no dependency on _localhost.conf's routing at all. Confirmed all apps still serve their manifest/icon/script/style at 200 after the change.
The injection itself
In the app's main location / block, after auth_request passes:
sub_filter '<head>' '<head><link rel="stylesheet" href="/_static/style.css"/><script src="/_static/script.js"></script>';
sub_filter_once off;
proxy_set_header Accept-Encoding ""; # sub_filter can't rewrite compressed responses
Accept-Encoding "" is mandatory whenever sub_filter is used — nginx can't pattern-match <head> inside gzipped bytes, so compression has to be disabled on that response.
Per-app reference
| Domain | PWA dir | Assets | What script.js/style.css actually does | Perimeter auth |
|---|---|---|---|---|
0.dph.am |
opencode/ |
manifest, 3 icons, script, style, sw.js | Injects refresh + home buttons into OpenCode's titlebar; only app with a real service worker (offline-caches the PWA shell only, never the live session) | ✅ auth_request + hardcoded upstream Basic Auth (see below) |
pi.dph.am |
pi/ |
manifest, icon, script | Mobile keyboard/viewport CSS fixes + a full floating toolbar (settings, scroll-to-bottom, refresh, session menu) for pi-webui | ✅ auth_request |
rsx.dph.am |
reasonix/ |
manifest, icon (svg+png), script, style | PWA metadata for Reasonix's dashboard | ✅ auth_request + query-token (see below) |
term.dph.am |
ttyd/ |
manifest, icon, script, style | Glassmorphism toolbar for tmux control (prev/next tab, new tab, kill pane, rename, mouse toggle) + a touch-scroll pill on mobile — see ttyd/README.md for the full mechanism (synthetic WheelEvents dispatched onto .xterm, since xterm.js ignores untrusted synthetic keyboard/wheel events otherwise) |
✅ auth_request |
pi-web.dph.am |
pi-web/ |
manifest, icon | Metadata only — app name "Studio Pi Web" (short name "Studio Pi"), π glyph + Apple-logo badge. Backend is the Mac Studio itself (192.168.1.189:49997) |
✅ auth_request |
pi-win.dph.am |
pi-win/ |
manifest, icon | Metadata only — app name "Windows Pi Web" (short name "Win Pi"), π glyph + Windows-blue quad-pane badge to distinguish it from pi-web on the home screen |
✅ auth_request |
pi-wsl.dph.am |
pi-wsl/ |
manifest, icon | Metadata only — app name "WSL Pi Web" (short name "WSL Pi"), π glyph + Ubuntu circle-of-friends badge. Same Windows box as pi-win (192.168.1.153) but the WSL instance on port 30142 vs Windows-native 30141 |
✅ auth_request |
dash.dph.am |
dash/ |
manifest, icon | Metadata only | ✅ auth_request |
dsh.dph.am |
dsh/ |
manifest, icon | Metadata only | ✅ auth_request |
c-de.dph.am |
(reuses opencode/) |
style only | Same OpenCode backend as 0.dph.am (both hit :4096), style-only injection |
App-level HTTP Basic Auth — see note below (not the nginx-auth-service cookie wall) |
ccli.dph.am (had no perimeter auth at all) and the codex/ PWA assets (never wired into a live site) were both removed 2026-09-20 as unused/unneeded.
⚠️ Security notes worth remembering
c-de.dph.amhas noauth_requestblock, and that's intentional, not a gap. It proxies to the exact same OpenCode instance as0.dph.am(:4096), which itself requires HTTP Basic Auth.0.dph.amauto-supplies that credential via a hardcoded header (see next point) so the nginx-auth-service cookie is the only thing you see;c-de.dph.amskips that header, so hitting it gets you OpenCode's own native Basic Auth browser prompt instead. Two different front doors onto the same app, both real, deliberately.0.dph.aminjects a hardcodedAuthorization: Basic <base64>header toward its upstream (proxy_set_header Authorization "Basic ..."in0.dph.am.conf). This is the credential thatc-de.dph.amdeliberately omits (above) — a real credential sitting in plaintext in the conf file. Treatsites-enabled/*.confas containing secrets, not just routing rules.rsx.dph.amauto-injects a bearer token into the query string if one isn't already present:
A second, independent secret beyond the auth_request cookie — again, live in the conf file itself.if ($args !~ token=) { set $args "token=<redacted>&$args"; }
The service worker gotcha (OpenCode only)
opencode/sw.js registers with scope: "/" from opencode/script.js:
navigator.serviceWorker.register("/_static/sw.js", { scope: "/" })
Service workers can normally only claim a scope at or below the directory they're served from — a worker at /_static/sw.js would be clamped to /_static/ unless the server explicitly widens it. 0.dph.am.conf does this:
location /_static/ {
alias /Users/dominick/Work/pwa/opencode/;
add_header Service-Worker-Allowed "/";
}
Without that header, registration throws a SecurityError and PWA offline support silently doesn't work. The service worker itself only ever caches the four static shell files (manifest + 3 icons) as an offline fallback — it explicitly passes through the live app HTML, API calls, and WebSocket/SSE traffic untouched, so an OpenCode session is never served stale.
Adding a new app to this pattern
mkdir ~/Work/pwa/<name>/— add at minimummanifest.json+ an icon. Addscript.js/style.cssonly if you actually want behavior changes.- Add a
sites-enabled/<name>.dph.am.conf(copy an existing one as a template —pi.dph.am.conffor Pattern A,dash.dph.am.conffor Pattern B). - Point asset
locationblock(s) directly at the file(s) on disk withalias—location = /manifest.json { alias /Users/dominick/Work/pwa/<name>/manifest.json; }for single files, orlocation /_static/ { alias /Users/dominick/Work/pwa/<name>/; }for a whole-directory bucket. No proxying needed — nginx serves it straight off the filesystem. - In the main
location /, after theauth_request /auth;block (don't skip this unless you have a specific reason not to — see security notes above), add:proxy_set_header Accept-Encoding ""; sub_filter '<head>' '<head><link rel="manifest" href="/manifest.json">...'; sub_filter_once off; sudo nginx -t && sudo nginx -s reload.
Other improvement ideas (not yet done)
Raised during the 2026-09-20 review; none of these are urgent, but worth doing opportunistically:
- Extract the repeated
auth_request/login/logoutblock into a sharedincludesnippet. It's identical (or near-identical) across0.dph.am,dash.dph.am,dsh.dph.am,pi.dph.am,pi-web.dph.am,rsx.dph.am,term.dph.am— seven copies of the same ~20 lines. Aninclude /opt/homebrew/etc/nginx/snippets/auth-gate.conf;would mean fixing the auth flow once instead of in nine files, and makes it obvious at a glance which sites don't include it (i.e.c-de.dph.am) rather than having to check each file for absence. - Pull the hardcoded secrets out of
sites-enabled/*.confentirely. The0.dph.amBasic Auth header and thersx.dph.amtoken are both real credentials sitting in plaintext inside nginx config files that aren't secrets-managed. Nginx supports reading these from a separateincluded file (e.g.secrets.conf,chmod 600, outside of any backed-up/synced directory) so the credential itself isn't duplicated across whatever the primary conf's exposure surface is (backups, screen-shares of the conf, etc.). - Add rate limiting on
/login. These*.dph.amhosts are directly reachable from the public internet (wildcard DNS → router → this box). The auth service's login endpoint has no visible brute-force protection at the nginx layer (nolimit_req). Worth alimit_req_zone/limit_reqonlocation /loginregardless of what the auth service itself does internally. - Standardize service-worker support if any other app besides OpenCode ever wants offline PWA behavior — right now the
Service-Worker-Allowedheader +sw.jscaching pattern is bespoke per-app rather than a reusable snippet.