exifcleaner-web/docs/standalone-html.md
forgejo_admin 6e52fd894f
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 26s
CI / E2E (Standalone single-file) (push) Successful in 1m14s
CI / E2E (Web) (push) Successful in 2m15s
docs(direction): standalone HTML + Android APK are the primary targets; drop iOS (#172)
## 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
2026-05-21 02:27:41 +04:00

7.8 KiB

Standalone single-file HTML

yarn build:web:standalone produces dist/web-standalone/index.html — one self-contained HTML file with the entire app inlined (JS, CSS, dependencies, ~830 KB). Open it directly in any modern browser, no server, no install, no internet. Hand the file to someone, they double-click it, it runs.

Note

This is a desktop distribution artifact. Android doesn't have a working "open local HTML in browser" flow even with a single file — for mobile, use the APK wrapper documented in android-apk.md.

Why this exists

The instinctive thought is "the deployed web bundle is just static HTML/JS/CSS, so dist/web/index.html should open from disk too." It doesn't, on Chromium-based browsers. The actual failure mode and root cause are documented in android-apk.md § Why an APK; the short version is that Chromium refuses to load ES modules from a file:// page over CORS, and there's no way to opt out from the JS side.

The fix is structural: bundle the entire app as one classic <script> (not <script type="module">) inside the HTML itself. With no external file references, there's no CORS check to fail. The trade-off is losing lazy-loading and PWA features, both of which the deployed build keeps.

How it differs from yarn build:web

Concern yarn build:web (deployed PWA) yarn build:web:standalone
Output dist/web/ — index.html + assets/ + manifest + service worker + icons dist/web-standalone/index.html — one file
Loading mechanism <script type="module" src="./assets/index-XXX.js"> <script>...inlined IIFE...</script>
File-URL compatible (Chromium CORS blocks ES module load) (no external loads)
HTTPS compatible (but no point — use the deployed build)
Size ~440 KB JS gzipped to ~180 KB, code-split into chunks ~830 KB single file (~310 KB gzipped)
Lazy-loaded PDF chunk (separate index-CpwmG4Zv.js, ~428 KB) (folded into main bundle — loaded eagerly)
Service worker / PWA install (no SW under file:// anyway)
CSP meta tag (default-src 'none'; script-src 'self'; …) (CSP doesn't apply usefully to a single-file bundle under file://; nothing external to gate)
Favicon + manifest (stripped — <link> tags would fail CORS under file://)
Best for Public/private web deployments, PWA install One-file hand-off to a single recipient, USB/email distribution

How it's built

vite.config.web.standalone.ts builds on the same source tree as vite.config.web.ts, with these differences:

  • vite-plugin-singlefile — inlines every chunk and CSS asset into the HTML output.
  • publicDir: false — skips copying public/ (manifest, icons, _headers) into the output. The deployed build needs them; the standalone build doesn't.
  • build.modulePreload: false — disables Vite's modulepreload polyfill (a small <script type="module"> IIFE). With everything already inlined, there are no preload targets, so the polyfill would be dead weight and an extra module-script tag we'd otherwise have to strip.
  • assetsInlineLimit: 256 KB — large enough to inline any small asset Vite references from JS (the deployed build's icons are 142 KB each — generous but harmless ceiling).
  • rollupOptions.output.inlineDynamicImports: true — required by singlefile, makes import("./...") calls resolve to the same bundle at build time.
  • standaloneHtmlFixupPlugin (custom closeBundle hook) — runs after singlefile, strips <link rel="manifest">, <link rel="icon">, the service-worker registration script, and rewrites the inlined <script type="module" crossorigin> to a plain <script>. The closeBundle hook reads the output file from disk after the build is fully written, which avoids Vite's transformIndexHtml ordering surprises (singlefile and any transformIndexHtml plugin can run in non-obvious orders relative to each other).
  • No PWA pluginVitePWA would register a service worker, which never registers under file:// and would just log an error on every load.
  • No CSP plugin — under file:// the meta CSP would either be unmatchable ('self' against a null origin), or would need 'unsafe-inline' to allow the inlined script. The deployed build's public/_headers and nginx.conf provide real CSP enforcement; the meta tag is redundant for the deployed path and broken for the standalone path. Just drop it here.

The output is a single HTML file with one inline <script> and one inline <style> block.

How to verify a build

yarn build:web:standalone
file --mime dist/web-standalone/index.html        # → text/html
wc -c dist/web-standalone/index.html              # → ~830000
ls dist/web-standalone/                           # → just index.html

Then open the file in a browser:

  • Linux: xdg-open dist/web-standalone/index.html
  • macOS: open dist/web-standalone/index.html
  • Windows: start dist\web-standalone\index.html

Or double-click index.html from a file manager. Drag-drop a sample image to confirm processing works (the strategies run entirely in-browser; no network call).

Distribution

Email, USB stick, Signal/AirDrop/Magic Wormhole — anything that moves a single file. Recipient saves it anywhere, opens it. Works offline forever — there's nothing to phone home about. If you want to verify nothing leaks: open DevTools → Network tab while using the app; the only entry should be the initial file:// load of index.html itself.

Trade-offs / caveats

  • No lazy-loading. The PDF strategy (~430 KB) loads eagerly even if the user never processes a PDF. For the hand-off use case this is fine; for a hosted deploy it would be wasteful.
  • No service worker. The deployed PWA caches itself for offline use after first visit; the standalone HTML is always offline because it never had a network connection to lose.
  • No PWA install. "Add to home screen" requires a service worker. Android users who want a real app icon should install the Android APK; users with a self-hosted PWA deploy can install from there.
  • No favicon. Stripped because <link rel="icon"> would trigger a CORS error in some browsers' console under file://. Cosmetic, no functional impact.
  • No manifest. Same reason.
  • Bigger initial load. 830 KB single file vs ~620 KB across two split chunks. ~30% size penalty for the convenience of a single deliverable.
  • No code-signing / no trust UI. The file is just HTML; the browser will not give it any "trusted app" treatment.

Why not just tell people to install Firefox

Firefox does load ES modules from file:// correctly (it treats same-folder file:// as same-origin). The deployed-style dist/web/ works there. But:

  • Most people don't have Firefox installed.
  • Asking them to install Firefox to use a metadata stripper has a worse UX than handing them one HTML file that works in whatever browser they already have.
  • The trust signal "open this attachment in your browser" beats "go install a different browser first."

The standalone HTML works everywhere, including Firefox, including private/incognito tabs.

Privacy invariants — re-verified for this path

  • No server-side processing (§1): the inlined code is the same WASM/pure-TS strategy registry as every other build path. Nothing fetches.
  • Fully offline (§2): the file never makes a network request. Verifiable in DevTools.
  • Forensic verification (§3): unchanged. The strategies are byte-identical to the deployed build.
  • RAW unsupported (§4): unchanged. Same registry.
  • No telemetry, no auto-update (§5): nothing was added. The file has no way to phone home.
  • Timestamps absent or epoch (§6): unchanged. The strategies handle this in the same code paths.