# Plan: PWA + Firebase Hosting for JamBuddy Status: **proposed**. Each phase is independently shippable; stop after any one. --- ## 0. Why this is low-risk The Electron shell (`electron/main.cjs`, `preload.cjs`) does **nothing but host a window** — no IPC, no native APIs, `sandbox:false` only to give the renderer full Web Audio. All logic lives in the React renderer, which already runs browser-only via `npm run dev`. So the web build is the same `vite build` output the desktop installer already ships; we just add a manifest + service worker and serve `dist/` over HTTPS. Firebase gives us HTTPS for free, which `getUserMedia` (the mic) requires — that's the only hard web constraint. Nothing in the app makes network calls today, so there's no API surface to re-host and no secrets to manage. --- ## Phase 1 — PWA scaffolding (no Firebase yet) Goal: `vite build` produces an installable, offline-capable web app served from any static host. ### 1.1 Add `vite-plugin-pwa` Only new dependency. Wraps Workbox under a Vite plugin — generates the service worker and injects the manifest. Cheaper than hand-rolling a SW and gets update flow for free. ```js // vite.config.js import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ plugins: [ react(), tailwindcss(), VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.ico', 'apple-touch-icon.png'], manifest: { name: 'JamBuddy — WhatTheFlat', short_name: 'JamBuddy', description: 'Real-time key and chord detection for live jams.', theme_color: '#0f0f0f', // matches bg-surface token background_color: '#0f0f0f', display: 'standalone', orientation: 'any', start_url: '/', scope: '/', icons: [ /* see 1.3 */ ], }, workbox: { globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], // Audio is captured live — nothing to cache from a remote. Precache the app shell only. navigateFallback: '/index.html', }, }), ], }) ``` ### 1.2 Create `public/` directory Vite serves `public/` at root and copies it verbatim into `dist/`. We need it for: - `favicon.ico`, `apple-touch-icon.png` (180×180) - Any static PWA icons referenced by the manifest - (Optional) `robots.txt` — not needed for an app, skip. The existing `src/assets/whattheflat-logo.png` is imported in `App.jsx` and bundled by Vite — leave that as-is; **don't** move it. The manifest icons must live in `public/` because they're referenced by URL, not imported. ### 1.3 Generate icon set from the existing logo Derive all required sizes from `assets/whattheflat-logo.png` (one source image → PNG output). Required manifest entries: ```js icons: [ { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ] ``` Plus `apple-touch-icon.png` (180×180) in `public/` — iOS ignores the manifest and uses this ``. The maskable variant needs safe-zone padding (~10%) so the logo isn't cropped by Android's circular mask. One-liner with ImageMagick if available, else export manually from the source PNG: ```bash magick assets/whattheflat-logo.png -resize 192x192 public/icon-192.png magick assets/whattheflat-logo.png -resize 512x512 public/icon-512.png magick assets/whattheflat-logo.png -resize 180x180 public/apple-touch-icon.png ``` ### 1.4 Update `index.html` Two problems with the current ``: 1. **No PWA tags** — add theme-color, manifest link, apple-touch-icon, apple-mobile-web-app-capable, description. 2. **CSP is hardcoded for dev** — `connect-src 'self' http://localhost:5173 ws://localhost:5173` and `script-src 'self' 'unsafe-eval'`. The `unsafe-eval` and the localhost entries are Vite dev artifacts; production Vite doesn't need `unsafe-eval`. Keep a CSP (it's good hygiene and the app is genuinely offline) but drop the dev bits: ```html ``` CSP (production): ``` default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self' blob:; ``` `blob:` in `media-src` is required because `getUserMedia` streams are blob-backed and the analyser reads them. Verify nothing else regresses — `vite-plugin-pwa` registers the SW from a same-origin script, so `'self'` covers it. Move the dev-only CSP into a Vite conditional so dev still allows HMR/websocket: ```js // vite.config.js — inject dev CSP via plugin, or keep a separate dev index template. ``` Simplest: keep one CSP in `index.html` with the production values, and in dev let Vite's own injected tags coexist (HMR works over the same origin websocket; CSP `connect-src 'self'` already allows it). If dev breaks, add `ws://localhost:5173` only in a dev-specific block. ### 1.5 Verify - `npm run build` → `dist/` contains `manifest.webmanifest`, `registerSW.js`, `sw.js`, and all `public/` icons. - Serve `dist/` locally with `npm run preview` and use Chrome DevTools → Application → Manifest (should show icons, installability ✓) and Service Workers (registered). - Install to desktop / "Add to Home Screen" on mobile. - Confirm the app still works **fully offline** after first load (airplane mode). Since there are no network calls, the only risk is the SW failing to precache — Workbox handles this. --- ## Phase 2 — Firebase Hosting setup Goal: serve `dist/` over HTTPS on a `*.web.app` domain (or custom domain). ### 2.1 Install the CLI ```bash npm install -D firebase-tools ``` ### 2.2 Init ```bash npx firebase init hosting ``` Answer: - **Public directory:** `dist` - **Single-page app (rewrite all urls to /index.html):** Yes — though the app has no client-side routing today, this protects future routes and is harmless. - **Set up automatic builds with GitHub:** **No.** (You're not using GitHub.) - **File `dist/index.html` already exists — overwrite?** **No.** This is critical — saying yes would clobber the Vite-built index. This creates: - `firebase.json` - `.firebaserc` ### 2.3 `firebase.json` (target shape) ```json { "hosting": { "public": "dist", "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "rewrites": [{ "source": "**", "destination": "/index.html" }], "headers": [ { "source": "**/*.@(js|css|svg|png|ico|woff2|webmanifest)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }, { "source": "/sw.js", "headers": [ { "key": "Cache-Control", "value": "no-cache" } ] }, { "source": "/index.html", "headers": [ { "key": "Cache-Control", "value": "no-cache" } ] } ] } } ``` Why the headers: Vite hashes asset filenames, so JS/CSS/icons are content-addressed → cache them **immortal**. `index.html` and `sw.js` must stay fresh so updates propagate — `no-cache` revalidates them every time. (Note: with `vite-plugin-pwa`'s `autoUpdate`, the SW self-updates; the `no-cache` on `sw.js` is belt-and-braces.) ### 2.4 First deploy ```bash npm run build npx firebase deploy --only hosting ``` Output: `https://.web.app`. The mic will now work because the origin is HTTPS. ### 2.5 `.gitignore` Add `.firebase/` and `firebase-debug.log` (already in a sane gitignore, but confirm). --- ## Phase 3 — (optional) custom domain In Firebase console → Hosting → Add custom domain. Add the DNS TXT/CNAME records it gives you. Lets Encrypt cert is auto-provisioned. No app change needed. --- --- ## Phase 4 — Web optimization checklist Things that matter specifically for shipping this app on the web. Most are cheap; do them before the first public deploy. ### 4.1 Drop the dead dependency `audiomotion-analyzer` is listed in `package.json` but **imported nowhere** in `src/`. Vite tree-shakes unused ESM, so it likely doesn't bloat the production bundle — but it still inflates `npm install` and is misleading. Either: - **Remove it** from `dependencies` (one-line `npm uninstall audiomotion-analyzer`), or - If something depends on it being present (it doesn't — grep confirms zero imports), wire it up. Recommend: remove. ### 4.2 Audit bundle size ```bash npm run build npx vite-bundle-visualizer # one-off, no config needed ``` Expected heavy bits: `pitchy` (small, pure JS) and React 19. `electron` / `electron-builder` are devDeps and excluded from the web build. If the visualizer shows anything surprising (e.g. a transitive dep pulling in moment/lodash), trim it. ### 4.3 Code-split the heavy, rarely-used views `DebugView`, `DrumView`, and `Tuner` are collapsible panels most users never open. They import theory helpers and own audio contexts. Lazy-load them so the initial bundle stays lean: ```jsx const Tuner = lazy(() => import('./components/Tuner')) const DebugView = lazy(() => import('./components/DebugView')) const DrumView = lazy(() => import('./components/DrumView')) ``` Wrap in ``. These are already behind toggle buttons, so there's no UX cost. This is the single biggest web win — the core pitch/chord flow loads first. ### 4.4 Audio permissions & UX on the web - **HTTPS** — covered by Firebase. `getUserMedia` is blocked on plain HTTP (except `localhost`). - **User gesture** — the existing "Start Listening" button already provides the gesture the browser requires to call `getUserMedia`. Don't change this; never auto-start listening on load. - **Mobile Safari quirks** — iOS Safari: - Requires `audio: { echoCancellation: false }` style may differ; test the existing `AudioCapture` constraints on an iPhone. - AudioContext must be resumed after a gesture — already handled since capture starts on click. - `sharedArrayBuffer` is not needed by this app (no worklets), so no COOP/COEP headers required. Good — don't add them; they'd complicate the Firebase static host. - **Permissions API** — optionally surface "microphone blocked" before the user clicks, using `navigator.permissions.query({ name: 'microphone' })`. The existing `micError` state already covers the denied case; this is a polish step, not required. ### 4.5 Mobile layout The app uses Tailwind with responsive `lg:` breakpoints already (e.g. the progressions sidebar is `hidden lg:block`). For a real mobile deploy: - Test the fretboard / piano SVGs at phone widths — they're SVG so they scale, but text labels may crowd. - The `controls bar` wraps with `flex-wrap` — good. Verify the key-lock select dropdowns are tappable. - Consider a `display: standalone` install prompt; the manifest already sets `standalone`. - This is a "play and look at the screen" app — portrait guitar fretboard orientation may want `orientation: 'portrait'` in the manifest instead of `'any'`. Decide based on the primary instrument view. ### 4.6 Performance for the audio hot loop Already mostly tuned (the dual-analyser design is deliberate). Web-specific notes: - `requestAnimationFrame` driving the analyser is fine on web — no change. - `Float32Array` chroma math is cheap. No change needed. - Don't add a Workbox runtime cache for audio — there's no audio to cache; everything is live. - If you ever add an AudioWorklet, its file must be served with the correct MIME and same-origin — Workbox precaches it as part of `globPatterns` only if it's emitted to `dist/`. Not needed today. ### 4.7 SEO / social (minimal — this is an app, not content) - `` and meta description (added in 1.4) are enough for the web.app URL to have a sane share card. - Add `og:title` / `og:description` / `og:image` to `index.html` if you want a nice link preview (point `og:image` to `/icon-512.png`). Optional. - No sitemap needed for a single-page offline app. ### 4.8 Keep Electron working The desktop build must not regress: - `electron-builder` config in `package.json` is untouched by any of the above. - The CSP change in `index.html` affects both targets — verify the desktop window still loads (it serves the built `dist/index.html`, so the new production CSP applies there too; that's fine and arguably better). - `vite-plugin-pwa` is a dev dependency; it only runs at build time and doesn't touch the renderer runtime in a way that breaks Electron. - Run `npm run electron:dev` after Phase 1 to confirm the Electron path still works. --- ## What stays Electron-only Nothing in this plan removes or forks code. The Electron and web builds share the exact same `dist/` output. The only per-target differences are: - Electron adds the `electron/main.cjs` shell and `electron-builder` packaging. - Web adds the PWA manifest + SW (ignored by Electron) and is served over HTTPS. If a future feature needs a native API (filesystem dialogs, auto-launch, tray), it would go through Electron's preload/IPC — which currently exposes nothing. At that point you'd add an `isElectron` guard and a `preload` IPC bridge. Not needed for any current feature. --- ## Suggested order of operations 1. **Phase 1.2** — create `public/`, drop in icons (Phase 1.3). 2. **Phase 1.1 + 1.4** — add `vite-plugin-pwa`, update `index.html`, relax CSP. 3. Verify with `npm run preview` + DevTools (Phase 1.5). 4. **Phase 2** — `firebase init hosting`, hand-tune `firebase.json`, first deploy. 5. **Phase 4.1** — remove dead `audiomotion-analyzer` dep. 6. **Phase 4.3** — lazy-load the three collapsible views. 7. Polish: mobile pass (4.5), bundle audit (4.2), optional OG tags (4.7). Each step is independently revertible. Stop after step 4 and you have a working, installable, offline PWA on the web; the rest is optimization.