## Summary Bring all direction-flavoured docs into sync with the May 2026 state of the project. **Primary distribution targets are now:** 1. **Desktop offline standalone HTML** (`dist/web-standalone/index.html`) — produced by `yarn build:web:standalone` 2. **Android APK** (Capacitor wrapper) — produced by `.github/workflows/build-android.yml` or `scripts/build-apk-local.sh` **Demoted to secondary:** - The deployed web PWA (`dist/web/`) is still buildable and self-hostable via the included Docker + Cloudflare Pages paths, but is no longer the recommended user-facing distribution. **Out of scope:** - iOS in any form (App Store, PWA via Safari, Add to Home Screen). ## What changed | File | Change | |---|---| | `.claude/rules/project-direction.md` | Main rewrite: "One code path, two distribution targets"; "Mobile = Android APK, not iOS"; "What's NOT in scope" updated; Phase E.1 issue list recast | | `.claude/rules/modernization-roadmap.md` | Phase E.1 table: #50 (iOS Photos picker) and #52 (PWA install prompt UX) marked out-of-scope; #23 recast from Web Share Target PWA to Android Intent filter; "PWA is sole channel" claim brought current; key constraints updated | | `.claude/rules/privacy-invariants.md` | §2 expanded to cover all three distribution paths (standalone inlines everything, APK uses Capacitor's localhost interceptor, self-host PWA caches via service worker) | | `CLAUDE.md` | Top of file + Tech Stack section reflect dual primary distribution | | `README.md` | Features list + Project Direction section reflect dual primary, iOS dropped, standalone-on-Android note updated to point at the APK | | `docs/android-apk.md` | Line 21 note flipped (APK is now primary, not "personal-distribution / not official"); comparison table relabelled; AAR conclusion updated | | `docs/deploying.md` | Reframed as the self-host PWA doc; iOS Safari install instructions removed; intro note clarifies this is secondary distribution | | `docs/architecture.md` | History note brought current — mentions APK #156 + standalone HTML as primaries | | `docs/PRIVACY_GAPS.md` | Android filesystem-isolation note updated to recommend the APK path | | `docs/standalone-html.md` | "No PWA install" trade-off bullet now points at the Android APK | 10 files changed, 81 insertions, 58 deletions. ## Phase E issues to close as out-of-scope This direction change makes two open issues out-of-scope. Suggested follow-up — close (not in this PR, since closing is a separate decision): - **#50** iOS Photos picker UX note — iOS dropped entirely - **#52** PWA install prompt UX — deployed PWA demoted to self-host only #23 (Web Share Target PWA) is recast rather than closed — the underlying "let users share files into MetaScrub from the Android gallery" feature is still wanted, but the implementation switches from the Web Share Target API in the manifest to a Capacitor `@capacitor/share` / native Intent filter. ## What I deliberately did NOT touch - **`CHANGELOG.md`** — historical release notes. The Phase G entry says "PWA is the sole distribution channel" which was accurate at the time; changelogs are snapshots, not living documents. - **`docs/superpowers/plans/2026-05-14-phase-g-rollout.md` + `docs/superpowers/specs/2026-05-14-phase-g-electron-retirement-design.md`** — historical phase plans/specs describing the work as it was at the time of execution. - **Playwright e2e mobile-iOS configs** — these test responsive layouts under an iOS Safari user agent, useful coverage independent of iOS being a shipping target. Removing them is a separate test-strategy decision, not a direction-doc concern. - **Source code** — only two iOS references in `src/`, both in comments describing the mobile-browser landscape generally (not iOS-gated code paths). No code change needed for the direction shift. ## Test plan - [x] All edits are documentation-only; no source code touched - [x] No `*.md` linting in CI; prettier-check only targets `src/**/*.{ts,tsx}` - [ ] Reviewer reads `.claude/rules/project-direction.md` first; satellites mirror its language - [ ] Decide whether to close #50 + #52 as out-of-scope as a follow-up 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Randa <obuvuyoviz26@gmail.com> Reviewed-on: http://forgejo.localhost:3000/forgejo_admin/exifcleaner-web/pulls/172
11 KiB
Deploying the web PWA (self-host)
Note
MetaScrub's primary distributions are the desktop standalone HTML and the Android APK (see
.claude/rules/project-direction.md). This doc covers the secondary, self-host path: deploying thedist/web/PWA bundle to your own infrastructure for users who'd rather hit a URL than download a file or sideload an APK. The deploy paths below are still supported but are no longer recommended as the main user-facing distribution.
The web build is plain static assets — HTML, JS, CSS, WASM, a manifest, a service worker. Anything that serves static files over HTTPS will host it. Two reference paths are documented below; pick whichever fits.
- Self-hosted Docker — full control, runs anywhere, no third party serves your code. The bundled
nginx.confalready has all required headers configured. - Cloudflare Pages — free hosting on a global CDN, auto-deploy from GitHub. Currently the GitHub Actions workflow is set to manual trigger only.
If you have no preference, start with self-hosted Docker + Cloudflare Tunnel — it's the lowest lock-in path and easy to move off of later.
Common requirements
- HTTPS is mandatory. Browsers refuse to register service workers over plain HTTP, which means PWA install will not work. The only exception is
localhost, useful for development. - Headers must be set. The Docker image's
nginx.confand the Cloudflare Pagespublic/_headersare intentionally identical (COOP/COEP for SharedArrayBuffer, CSP with'wasm-unsafe-eval'for WASM, immutable cache for/assets/*, no-cache for/sw.js). If you change one, change the other. - The build output is
dist/web/. Produced byyarn build:web. Self-hosted, you'll either copy this into the Docker image (the includedDockerfiledoes this) or serve it directly from any static host.
Self-hosted Docker
The included Dockerfile is a multi-stage build: stage 1 builds the bundle with Node 22; stage 2 serves it with nginx Alpine. Final image is around 90 MB.
Build and run locally
docker build -t metascrub .
docker run -d -p 8080:80 --name metascrub metascrub
# → reachable at http://localhost:8080
This is enough for local testing. For phone testing or sharing, you need HTTPS — three concrete options below.
Option A: Cloudflare Tunnel (free, no port-forwarding)
Easiest path. Install cloudflared, point it at the local container, get a public HTTPS URL. Works from any network — no router or firewall configuration.
Quick test (random URL, throwaway):
cloudflared tunnel --url http://localhost:8080
# → outputs https://<random>.trycloudflare.com
Stable URL on a domain you own (requires a free Cloudflare account with the domain on Cloudflare's nameservers):
cloudflared tunnel login
cloudflared tunnel create metascrub
cloudflared tunnel route dns metascrub metascrub.example.com
cloudflared tunnel run --url http://localhost:8080 metascrub
Cloudflare provisions and renews TLS certs automatically.
Option B: VPS with a reverse proxy
The "I own the box" version. Any cheap VPS works (Hetzner, DigitalOcean, Vultr — typically $4–6/month). Run the Docker container on port 8080, put any reverse proxy in front to terminate TLS. Both nginx and Caddy work; pick what you already know.
The Docker container's internal nginx already sets all required response headers (COOP/COEP/CSP). The reverse proxy preserves response headers by default, so no extra header configuration is needed at the proxy layer.
Caddy (shortest config, auto-TLS)
Caddy provisions and renews Let's Encrypt certs automatically. Full config:
metascrub.example.com {
reverse_proxy localhost:8080
}
caddy run (or systemd unit), point your DNS at the VPS, done.
nginx + certbot (most operators already know it)
# /etc/nginx/sites-enabled/metascrub
server {
listen 80;
server_name metascrub.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Then provision the cert and rewrite the config to enable HTTPS:
sudo certbot --nginx -d metascrub.example.com
Certbot adds the SSL block and the HTTP→HTTPS redirect. It also installs a renewal cron/timer.
Option C: Tailscale Funnel (home server, no exposed ports)
If the server is at home and you'd rather not expose ports on your router, Tailscale Funnel exposes a Tailscale-internal service to the public internet over HTTPS. Free for personal use.
sudo tailscale funnel --bg --https=443 8080
The service is reachable at https://<machine>.<tailnet>.ts.net.
Cloudflare Pages
Free tier: 500 builds/month, unlimited requests, unlimited bandwidth, 100 custom domains per project. No credit card required.
The GitHub Actions workflow at .github/workflows/deploy-web.yml handles the deploy. It is currently set to manual trigger only (workflow_dispatch) so it doesn't run before secrets are configured. Re-enable automatic deploys by editing the on: block per the comment in the file.
1. Create a Cloudflare account
Sign up at https://dash.cloudflare.com/sign-up.
2. Capture your Account ID
After login, the dashboard URL is https://dash.cloudflare.com/<account-id>. The same value appears in the Workers & Pages tab right sidebar with a copy button.
3. Create an API token scoped to Pages
Use a scoped token, not the global API key.
- Top-right profile icon → My Profile → API Tokens → Create Token
- Pick Custom token
- Permissions:
Account→Cloudflare Pages→Edit - Account Resources:
Include→ your account - Continue → Create Token
- Copy the token now — Cloudflare shows it once
4. Add the two secrets to GitHub
In the repo:
- Settings → Secrets and variables → Actions → New repository secret
- Add
CLOUDFLARE_API_TOKEN(token from step 3) - Add
CLOUDFLARE_ACCOUNT_ID(account ID from step 2)
5. Trigger the first deploy
While the workflow is on workflow_dispatch:
- Go to the Actions tab → Deploy Web App to Cloudflare Pages → Run workflow → pick branch → Run
- The first run auto-creates the Pages project with name
exifcleaner-web(the legacy CF project name — kept as-is to avoid orphaning an existing deploy; rename via the Cloudflare dashboard if desired)
To re-enable auto-deploys on every push, edit the on: block in the workflow file. Once enabled:
- Push to
master→ production deployment - Pull request to
master→ preview deployment per commit (URL posted in the action output)
6. Find your URL
- Production:
https://exifcleaner-web.pages.dev - Preview:
https://<commit-hash>.exifcleaner-web.pages.dev
Both visible at: Cloudflare dashboard → Workers & Pages → exifcleaner-web → Deployments.
7. Custom domain (optional)
Free.
- Workers & Pages →
exifcleaner-web→ Custom domains → Set up a custom domain - Enter the domain
- Add the DNS records Cloudflare shows. If the domain is on Cloudflare's nameservers, this is automatic.
Installing the self-hosted PWA from a phone
Note
The recommended Android path is the Capacitor APK (
docs/android-apk.md) — fully offline from first launch, no HTTPS deploy required. The instructions below are only for users hitting a self-hosted PWA deploy. iOS is out of scope for MetaScrub overall; if it happens to work via Safari Add-to-Home-Screen on a self-host deploy that's a side effect, not a supported target.
Android (Chrome) — self-hosted PWA
- Open the URL
- Wait a few seconds for the service worker to register
- ⋮ menu → Install app (older Chrome: "Add to Home Screen")
- Confirm. Icon lands on the home screen, launches in standalone mode (no browser chrome). Subsequent launches work offline.
Offline behaviour
The first visit (online) caches the app shell + WASM modules via the service worker. After that, the PWA loads from cache regardless of connectivity. The actual file processing is in-browser anyway — files never leave the device — so offline use is a first-class case.
Headers configuration
Two files, one truth. Keep them in sync.
| File | Used by | Format |
|---|---|---|
nginx.conf |
Docker image | nginx add_header directives |
public/_headers |
Cloudflare Pages | Cloudflare Pages headers syntax |
Both apply the same set:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corpX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: no-referrerContent-Security-Policywith'wasm-unsafe-eval'- Long-cache for
/assets/*, no-cache for/sw.js
Troubleshooting
PWA install option doesn't appear in the Chrome menu — three things to check: (1) the URL is HTTPS, (2) the service worker registered (DevTools → Application → Service Workers should show one as "activated"), (3) the manifest is reachable at /manifest.webmanifest and parses cleanly. Without all three, Chrome won't surface the install prompt.
Service worker doesn't register on Cloudflare Pages — check response headers in the Network tab. COOP/COEP must be on the HTML response. If they're missing, the _headers file likely didn't make it into the build output. Verify dist/web/_headers exists after yarn build:web.
Cloudflare workflow fails with "Authentication error" — the API token scope must be Account → Cloudflare Pages → Edit. Account-level, not zone-level.
Cloudflare workflow fails with "account_id is required" — CLOUDFLARE_ACCOUNT_ID is missing or wrong. It's the hex string from the dashboard URL, not the account email.
Phone shows "Add to Home Screen" but the icon launches in a normal browser tab — manifest didn't pass Chrome's PWA criteria. Check that both 192px and 512px icons exist with purpose: "any maskable" and display: "standalone" is set in manifest.webmanifest.
Self-signed cert on a LAN-only setup gives a security warning — browsers will not register service workers behind self-signed certs even if you click through the warning. Use Cloudflare Tunnel or Tailscale Funnel for HTTPS without exposing your home network.