feat: PWA support + QR pairing + Tailscale auto-discovery for mobile access - #551
Open
haotianliangye wants to merge 28 commits into
Open
feat: PWA support + QR pairing + Tailscale auto-discovery for mobile access#551haotianliangye wants to merge 28 commits into
haotianliangye wants to merge 28 commits into
Conversation
Lets the operator pair a phone to Pi Web by scanning a QR code on the desktop. The phone lands already authenticated — no password prompt. Flow: desktop: open top-bar phone icon → modal shows QR + URL + username + PIN phone: scan QR → browser opens → drops into Pi Web with a 30-day session cookie Components added: - components/PairDevice.tsx — modal with QR (qrcode SVG) and copy buttons - app/api/pair-tokens/route.ts — issues one-time tokens (5 min TTL, single use) - app/api/pair-info/route.ts — bound hostname + auth requirement - app/api/pair-password/route.ts + regenerate — runtime 6-digit PIN - lib/pair-tokens.ts — token store (file-backed, shared across worker processes) - lib/runtime-password.ts — runtime PIN store - lib/session-store.ts — HMAC-signed session cookies (30-day window) - lib/session-store.test.mjs — round-trip, tampering, expiry coverage Auth flow: - proxy.ts skips auth for loopback (operator's own browser) - network requests (phone over Tailscale) require either a valid session cookie OR Basic Auth (PI_WEB_PASSWORD, auto-generated 6-digit PIN) - a ?pair=<token> query param exchanges for a fresh 30-day cookie Bindings: - 0.0.0.0 listens on all interfaces; the launcher writes the Tailscale IP to .pi-web-hostname so the QR encodes a dialable address even when the bind address is a wildcard - bin/pi-web.js simplified — no Tailscale sentinel; operator passes -H explicitly when not using 0.0.0.0 Dev-mode fix: - scripts/with-clean-home.js now junctions ~/.pi into the temp HOME so SessionManager.listAll() walks the real session storage and shows existing projects in dev (not just production) Docs: - 4 README translations updated (en/zh/ja/ru) — dropped Tailscale sentinel syntax, dropped "long random password" guidance - added i18n keys: pair.connectPhone, pair.title, pair.scanWithPhone, pair.urlLabel, pair.usernameLabel, pair.passwordLabel, etc. Dependencies: - add: qrcode + @types/qrcode - removed npm scripts: start:tailscale, dev:tailscale - removed: bin/tailscale-ip.js, scripts/bind-ip.js
New client-only overlays that surface progressive web app affordances without modifying the existing app shell: - PwaInstallPrompt — captures Chromium/Edge `beforeinstallprompt`, defers it, and renders a small floating chip in the bottom-right corner. The chip appears once a PWA install is available and is hidden once the user accepts or dismisses. - PwaIosHint — Safari/Firefox/iOS do not fire `beforeinstallprompt`, so this component detects iOS (iPhone/iPad and iPadOS 13+ desktop-class UAs), and shows a top-centered hint explaining the Share-sheet install flow. Dismissal is remembered for 30 days via localStorage. - PwaUpdateToast — listens for the `updatefound` → `statechange === 'installed'` lifecycle on the service worker, and when a new SW is waiting to activate, surfaces a toast offering an in-place reload. Communicates with the SW via `SKIP_WAITING` postMessage; the SW's message handler (added in the next commit) calls `self.skipWaiting()`, after which we reload to pick up the new bundle. All three components render via `createPortal` and bail out to `null` when not relevant, so the surrounding UI is unchanged. They are mounted from `app/layout.tsx` (the import lines are added there in the same series of commits). Includes tests for each component using node:test (matching the rest of the repo). Tests stub `document`, `window`, `localStorage`, and the `beforeinstallprompt` event surface so they run outside a browser.
Manifest (app/manifest.ts):
- Add maskable icon (`/icons/icon-512-maskable.png`) so the PWA installs
with adaptive rounded shapes on Android instead of being clipped to the
foreground square.
- Declare desktop-wide and mobile-narrow screenshots; Play Store /
Microsoft Store installers render these in the install dialog to show
the user what they're getting.
- Add a "New session" shortcut (`/?action=new`) with its own 96×96 icon,
exposed in the system app launcher long-press menu.
- Loosen `orientation` to "any" so phone/tablet users can rotate freely
instead of being pinned to portrait.
Icons + screenshots:
- public/icons/icon-512-maskable.png — adaptive icon foreground
- public/icons/shortcut-new.png — shortcut icon
- public/screenshots/desktop-wide.png — 1280×720 install preview
- public/screenshots/mobile-narrow.png — 750×1334 install preview
CSS (app/globals.css):
- Add `.pwa-install-chip` and `.pwa-ios-hint` styles. They sit at the
highest z-index (`2147483646`) so they float over every other overlay
(modals at 1100, update toast at 2147483646). Safe-area insets are
respected on notched devices.
Service Worker (public/sw.js):
- Wipe every `${CACHE_PREFIX}-*` cache before re-priming in `install`.
Stale chunk entries from a previous install survive in the same-named
cache and break module resolution when the underlying `node_modules`
layout changes (pnpm → npm migration, or any rebuild where hashed
chunk paths shift). Re-priming from scratch guarantees the cache only
contains what the current `PRECACHE_URLS` points at.
- Listen for `{ type: 'SKIP_WAITING' }` messages and call
`self.skipWaiting()`. The Update Toast posts this message when the
user clicks "Reload" so the new SW activates immediately.
- Precache the new maskable icon, shortcut icon, and screenshots so
they survive offline first-launch.
Service Worker tests (public/sw.test.mjs):
- Cover the SKIP_WAITING message handler (calls skipWaiting for the
right type, ignores unrelated messages and malformed payloads).
Registration (components/PwaRegistration.tsx):
- In dev mode, unregister any active service worker left over from a
previous `next start` build. Without this, the production SW keeps
intercepting `/_next/static/*` requests in dev and serves stale
chunks, which Next 16 surfaces as "module factory is not available"
runtime errors. After unregistering, force a reload so the next
page load genuinely goes through the network.
A grab bag of small changes that keep the dev experience clean under
the strict TypeScript / Next 16 dev overlay:
TypeScript type guards on `AssistantMessage.content`:
- app/api/models-config/test/route.ts
- components/ChatWindow.tsx
- lib/session-title.ts
`@earendil-works/pi-ai`'s `AssistantMessage.content` is typed as
`AssistantContentBlock[]`, which is a discriminated union. The naive
`.filter((b) => b.type === "text")` produced a `(AssistantContentBlock
| { type: 'text'; text: string })[]` that the compiler still treated
as the wide type. Switching to `.filter((b): b is TextContent => ...)`
narrows the result so the downstream `.map((b) => b.text)` typechecks
without an `as TextContent` cast.
hooks/useTheme.ts:
- `document.startViewTransition` is not yet in the default lib types on
every platform we ship to. Replace the unguarded access with a type
guard, and bail to the synchronous `apply()` path when the API is
missing so the theme still flips on browsers without View Transitions.
- No runtime behavior change for browsers that have the API.
hooks/useAgentSession.ts:
- `loadModels` already swallows its own errors (network failures, JSON
parse failures, AbortError on cleanup). Add a defensive `.catch(() =>
{})` so anything that ever bubbles up is silenced — Next 16's dev
overlay still reports the AbortError source line even when caught,
which previously surfaced as an unhandledRejection noise.
components/ChatWindow.tsx:
- Same AbortError-catching comment near the new-session update check.
- Same TextContent guard on the user-message text extraction path.
Lockfile regeneration covering dependency tree changes since the v0.8.8 release. Most notable: the addition of `qrcode` (and `@types/qrcode`) for the QR rendering in the PairDevice modal, plus the transitive resolution churn that came with it.
Three project-local utility scripts. None were previously tracked: - scripts/with-clean-home.js — Windows-only launcher that points HOME/USERPROFILE at a clean temp dir before spawning the underlying command, sidestepping the EPERMs Next 16's webpack hits when its filesystem glob walks the user's shell-folder symlinks. Junctions ~/.pi into the temp HOME so dev runs still see real pi-coding-agent session storage. Non-Windows is a transparent passthrough. - scripts/generate-pwa-icons.mjs — Idempotently derives the PWA assets (maskable icon, shortcut icon, desktop-wide + mobile-narrow install screenshots) from `public/icons/icon-512.png` via sharp. Triggered by `npm run icons:generate`. - scripts/measure-node-modules.js — Reports how the project's node_modules/ is laid out (junctions, symlinks, hardlinks to the store, unique files). Diagnostic only; useful for verifying that the package manager actually de-duplicated shared content rather than copying it everywhere.
Bring the documentation in line with the actual behavior of the new Connect Phone modal and runtime PIN. Three things changed: 1. Remote Access — was "set PI_WEB_PASSWORD when binding non-loopback", now describes the default of an auto-generated 6-digit PIN persisted to ~/.pi-web/, with PI_WEB_PASSWORD as an opt-in override. 2. Mobile Access — was "open this URL on the phone, type the password". Now describes the QR code path (one-time pairing token exchanged for a 30-day session cookie, no password typing on the phone) and notes that the 6-digit PIN prompt only triggers when the phone lands via the bare URL instead of the QR. 3. Option table — PI_WEB_PASSWORD row goes from "Authentication disabled" by default to "Auto-generated 6-digit PIN", matching the new behavior. All four language variants (en / zh-CN / ja / ru) updated identically.
… cache
The session history sidebar was waiting tens of seconds to render because
`/api/sessions` blocked on `git rev-parse` for every unique cwd. This
restructures the path so the response ships immediately and the slow work
happens in the background, then adds a cross-process disk cache so cold
boots skip the JSONL scan + git fan-out entirely.
1. lib/worktree.ts — Semaphore(8) around the git execFile call. Windows
spawns many git processes at once overwhelm antivirus/process-creation;
the bound prevents the "all 20 git procs block on the same AV hook"
stall while still parallelizing small cwd sets.
2. lib/session-reader.ts — split the project-info step into sync + async:
- applyCachedProjectInfo(session) reads __piProjectCache synchronously
so the response can ship without waiting on git.
- scheduleProjectEnrichment(sessions) is fire-and-forget; it walks
unique cwds through resolveProject, populating the cache for the
next request. Errors are swallowed.
3. lib/session-reader.ts — cross-process disk cache at
~/.pi/agent/.pi-web-list-cache.json. Schema versioned, with the
sessions-dir mtime stamped so the next cold boot can decide whether
to rescan. TTL is gone; mtime is the only signal. invalidateSessionListCache
removes the disk file too, so every existing caller (rpc-manager +
4 routes) automatically invalidates both layers.
4. lib/session-reader.ts — safeguard against an empty scan clobbering a
non-empty cache: writeListCacheToDisk refuses to write [] if either
an existing non-empty cache is present or the sessions dir contains
JSONL files. Logs a warning so the original bug surfaces in dev logs
instead of as silent data loss.
5. lib/session-reader.ts — resolveSessionPath now verifies the cached
path still exists on disk and falls through to force:true scan on
miss. Necessary because the disk cache stores absolute paths that
were valid in a prior dev server's cleanHome (see with-clean-home.js);
a stale path would otherwise dangle.
6. app/api/sessions/route.ts — Promise.all no longer blocks on git.
Persisted sessions keep their projectRoot (already enriched inside
loadAllSessions); runtime sessions get applyCachedProjectInfo from
the sync cache; scheduleProjectEnrichment runs in the background.
7. instrumentation.ts — fire-and-forget listAllSessions() +
scheduleProjectEnrichment() at server boot. Disk cache layer makes
the very first user request fast even before any client request lands.
8. components/SessionSidebar.tsx — stale-while-revalidate. A ref tracks
whether we have ever rendered a non-empty list; subsequent refreshes
skip the Loading state and keep old data on screen during slow
refetches.
9. components/ChatWindow.tsx + hooks/useAgentSession.ts — replaced
AbortController.abort() in two background useEffects with a
cancelled flag. Next.js 16 dev overlay tracks the abort source
line even when downstream code catches the AbortError, surfacing
"Runtime AbortError: signal is aborted without reason" as noise.
The cancelled flag drops late results without producing an abort.
10. next.config.ts — devIndicators: false hides the bottom-right
build/error overlay button. Belt-and-braces with agegr#9.
Tests
- 7 new tests in session-reader.test.mjs (5 disk-cache + 2 safeguard).
- runtime-route.test.mjs updated to assert the new pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local security audit output contains raw findings (vulnerability descriptions, affected file paths, sometimes exploit snippets) that would expose the project's open attack surface if pushed to a public repo. Keep these out of version control by default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # bin/pi-web.js # package-lock.json
- AGENTS.md: commit the BEGIN/END nextjs-agent-rules block that next dev appends on every restart, per the embedded note's advice (removing it from a diff only re-creates the uncommitted change). - package-lock.json: 1321 +/245 lines after npm install syncing node_modules to the upstream-resolved dependency graph (pi 0.84.2 + vulnerable dep fixes from upstream merge ac8df91).
Documents the 4-step sync (git upsync alias + npm install + dev server restart + verify) and the three recurring traps that bit us on the first sync: - AGENTS.md auto-append block from Next.js 16's next dev - npm install re-aligning package-lock.json (hundreds of lines) - dev server's in-memory react/react-dom drifting apart after npm install, causing a 500 with 'Incompatible React versions' Also captures the ~40 pre-existing test failures (fork-introduced ./runtime-password import without .ts extension, Windows symlink EPERMs, missing I18nProvider wrappers) so future syncs don't try to 'fix' them as part of the merge. AGENTS.md gets the full English version; README.zh-CN.md gets a condensed Chinese summary under the existing 开发 section.
Lets the operator pair a phone to Pi Web by scanning a QR code on the desktop. The phone lands already authenticated — no password prompt. Flow: desktop: open top-bar phone icon → modal shows QR + URL + username + PIN phone: scan QR → browser opens → drops into Pi Web with a 30-day session cookie Components added: - components/PairDevice.tsx — modal with QR (qrcode SVG) and copy buttons - app/api/pair-tokens/route.ts — issues one-time tokens (5 min TTL, single use) - app/api/pair-info/route.ts — bound hostname + auth requirement - app/api/pair-password/route.ts + regenerate — runtime 6-digit PIN - lib/pair-tokens.ts — token store (file-backed, shared across worker processes) - lib/runtime-password.ts — runtime PIN store - lib/session-store.ts — HMAC-signed session cookies (30-day window) - lib/session-store.test.mjs — round-trip, tampering, expiry coverage Auth flow: - proxy.ts skips auth for loopback (operator's own browser) - network requests (phone over Tailscale) require either a valid session cookie OR Basic Auth (PI_WEB_PASSWORD, auto-generated 6-digit PIN) - a ?pair=<token> query param exchanges for a fresh 30-day cookie Bindings: - 0.0.0.0 listens on all interfaces; the launcher writes the Tailscale IP to .pi-web-hostname so the QR encodes a dialable address even when the bind address is a wildcard - bin/pi-web.js simplified — no Tailscale sentinel; operator passes -H explicitly when not using 0.0.0.0 Dev-mode fix: - scripts/with-clean-home.js now junctions ~/.pi into the temp HOME so SessionManager.listAll() walks the real session storage and shows existing projects in dev (not just production) Docs: - 4 README translations updated (en/zh/ja/ru) — dropped Tailscale sentinel syntax, dropped "long random password" guidance - added i18n keys: pair.connectPhone, pair.title, pair.scanWithPhone, pair.urlLabel, pair.usernameLabel, pair.passwordLabel, etc. Dependencies: - add: qrcode + @types/qrcode - removed npm scripts: start:tailscale, dev:tailscale - removed: bin/tailscale-ip.js, scripts/bind-ip.js
New client-only overlays that surface progressive web app affordances without modifying the existing app shell: - PwaInstallPrompt — captures Chromium/Edge `beforeinstallprompt`, defers it, and renders a small floating chip in the bottom-right corner. The chip appears once a PWA install is available and is hidden once the user accepts or dismisses. - PwaIosHint — Safari/Firefox/iOS do not fire `beforeinstallprompt`, so this component detects iOS (iPhone/iPad and iPadOS 13+ desktop-class UAs), and shows a top-centered hint explaining the Share-sheet install flow. Dismissal is remembered for 30 days via localStorage. - PwaUpdateToast — listens for the `updatefound` → `statechange === 'installed'` lifecycle on the service worker, and when a new SW is waiting to activate, surfaces a toast offering an in-place reload. Communicates with the SW via `SKIP_WAITING` postMessage; the SW's message handler (added in the next commit) calls `self.skipWaiting()`, after which we reload to pick up the new bundle. All three components render via `createPortal` and bail out to `null` when not relevant, so the surrounding UI is unchanged. They are mounted from `app/layout.tsx` (the import lines are added there in the same series of commits). Includes tests for each component using node:test (matching the rest of the repo). Tests stub `document`, `window`, `localStorage`, and the `beforeinstallprompt` event surface so they run outside a browser.
Manifest (app/manifest.ts):
- Add maskable icon (`/icons/icon-512-maskable.png`) so the PWA installs
with adaptive rounded shapes on Android instead of being clipped to the
foreground square.
- Declare desktop-wide and mobile-narrow screenshots; Play Store /
Microsoft Store installers render these in the install dialog to show
the user what they're getting.
- Add a "New session" shortcut (`/?action=new`) with its own 96×96 icon,
exposed in the system app launcher long-press menu.
- Loosen `orientation` to "any" so phone/tablet users can rotate freely
instead of being pinned to portrait.
Icons + screenshots:
- public/icons/icon-512-maskable.png — adaptive icon foreground
- public/icons/shortcut-new.png — shortcut icon
- public/screenshots/desktop-wide.png — 1280×720 install preview
- public/screenshots/mobile-narrow.png — 750×1334 install preview
CSS (app/globals.css):
- Add `.pwa-install-chip` and `.pwa-ios-hint` styles. They sit at the
highest z-index (`2147483646`) so they float over every other overlay
(modals at 1100, update toast at 2147483646). Safe-area insets are
respected on notched devices.
Service Worker (public/sw.js):
- Wipe every `${CACHE_PREFIX}-*` cache before re-priming in `install`.
Stale chunk entries from a previous install survive in the same-named
cache and break module resolution when the underlying `node_modules`
layout changes (pnpm → npm migration, or any rebuild where hashed
chunk paths shift). Re-priming from scratch guarantees the cache only
contains what the current `PRECACHE_URLS` points at.
- Listen for `{ type: 'SKIP_WAITING' }` messages and call
`self.skipWaiting()`. The Update Toast posts this message when the
user clicks "Reload" so the new SW activates immediately.
- Precache the new maskable icon, shortcut icon, and screenshots so
they survive offline first-launch.
Service Worker tests (public/sw.test.mjs):
- Cover the SKIP_WAITING message handler (calls skipWaiting for the
right type, ignores unrelated messages and malformed payloads).
Registration (components/PwaRegistration.tsx):
- In dev mode, unregister any active service worker left over from a
previous `next start` build. Without this, the production SW keeps
intercepting `/_next/static/*` requests in dev and serves stale
chunks, which Next 16 surfaces as "module factory is not available"
runtime errors. After unregistering, force a reload so the next
page load genuinely goes through the network.
When `tailscale serve` is configured, the launcher now advertises the HTTPS URL (`https://<node>.ts.net/`) instead of the bare Tailscale IPv4. Only HTTPS counts as a secure context for the browser, so the phone can install Pi Web as a real PWA in standalone mode (no browser UI). The Host header on the wire stays the bare hostname, so `lib/request-security.ts` only needs the bare hostname in `PI_WEB_HOSTNAME`. The full URL flows into `.pi-web-hostname` so the pair-info and pair-tokens routes emit a working HTTPS pair URL. Falls back to `tailscale ip -4` then 127.0.0.1 if serve isn't set up, matching prior behavior.
…nv var The previous change had a hidden bug: routes read PI_WEB_HOSTNAME first to construct the QR/pair URL. The launcher now writes the full HTTPS URL to .pi-web-hostname but normalizes PI_WEB_HOSTNAME to a bare hostname (request-security.ts needs that form for its whitelist), so the env var takes priority and the file's full URL never reaches the client. Invert the priority: the file is the canonical addressable URL the launcher wrote, the env var is just the bare hostname for security. Falls back to env var only when the file is missing (e.g. legacy deployment that sets PI_WEB_HOSTNAME but doesn't write the file).
The branch returned args[property] which TypeScript widens to unknown even though we just typeof-checked it. Hoist the lookup into a local, re-check, and return. This unblocks 'npm run build' (was failing with TS2322 in CI since 5.4 strictness landed).
`npm start` and `npm run start:lan` used to spawn `next start` directly, bypassing bin/pi-web.js. `npm run dev` and `npm run dev:lan` used to spawn `next dev` through scripts/with-clean-home.js, also bypassing the launcher. Either way, the .pi-web-hostname file never got updated to the tailscale serve HTTPS URL, so the QR code kept encoding `http://100.75.x.x:30141/` (HTTP) instead of `https://...ts.net/` (HTTPS) — which Chrome needs as a secure context to install the PWA in standalone mode. - Extract hostname detection into bin/host-info.js (single source of truth for: .pi-web-hostname content + PI_WEB_HOSTNAME env var). - bin/pi-web.js now delegates to it. - scripts/with-clean-home.js also delegates to it before spawning next dev, so dev mode picks up tailscale serve too. - npm start / npm run start:lan now route through bin/pi-web.js so production mode picks up the launcher too. After this, restarting via any of `npm start`, `npm run dev`, or `npm run dev:lan` will write `https://...ts.net` (when tailscale serve is configured) to .pi-web-hostname.
The four-script matrix (start / start:lan / dev / dev:lan) was over-engineered for a personal tool. Both start paths now bind `0.0.0.0` by default — Tailscale proxies to localhost so loopback binding isn't needed, and 0.0.0.0 also accepts plain LAN clients. `bin/host-info.js` now auto-detects a real LAN address via `os.networkInterfaces()` as a fallback below Tailscale. Virtual adapters (Hyper-V, VMware, VirtualBox, Docker, WSL, Tailscale, Bluetooth, etc.) are filtered by name; link-local and loopback addresses are filtered by value. Remaining candidates are ranked by private range — 192.168.0.0/16 first, then 10.0.0.0/8, then 172.16-31.0.0/12 — so home networks get picked over corporate / Docker ranges. Resolution order is now: bound override > tailscale serve URL > tailscale bare IP > LAN IP > loopback. `start:lan` and `dev:lan` are gone; pass `-H 127.0.0.1` if you actually need loopback-only.
The phone reaches the dev server through the Tailscale Serve hostname (`https://<node>.ts.net/`) — that's the secure-context URL the QR code embeds and the address Chrome installs the PWA from. The dev server sees those requests as cross-origin (Host: duój.taildee88d.ts.net vs the bind address 127.0.0.1) and Next.js refuses to serve /_next/static/* chunks without an explicit allowlist entry, leaving the app as a static HTML shell with no JS hydration. Add `*.ts.net` to allowedDevOrigins next to the existing 100.*.*.* Tailscale IP entry so dev mode works through tailscale serve too.
The black status bar in PWA standalone mode comes from `manifest.theme_color`, which we had pinned to the dark-theme value (`#1a1a1a`) even though the app's default light mode uses `--bg: #ffffff` / `--bg-panel: #f5f5f5`. That mismatch produced a hard black bar above a white body in light mode. Switch `theme_color` to `#f5f5f5` (matches the panel — the bar immediately below the status bar — so the transition is seamless) and `background_color` to `#ffffff` so the splash screen matches the app body while the bundle loads. Trade-off: `theme_color` is fixed at install time, so users who pick dark theme in settings still get a light status bar. Acceptable until the project ships per-theme manifests or a runtime theme-color shim.
…t override When the OS is in dark mode but the app body is light, Chrome's WebAPK builder would still read the meta theme-color media query and pick `#1a1a1a` for the status bar (it consults the meta tag as a fallback when building the WebAPK). Mirror the manifest's light value in `viewport.themeColor` so both sources agree. Also update PwaManifest.test.mjs to assert the new colors.
The OS may be in dark mode while the app is in light mode (or the reverse). Mirror the manifest's `#f5f5f5` in both meta queries so the system status bar is always light gray, matching the panel color the app draws immediately below it.
`id: "/"` made Chrome reuse any prior WebAPK metadata for this origin. After the earlier HTTP-only install attempts left a shortcut-shaped WebAPK entry behind, every subsequent reinstall with HTTPS + SW + valid manifest still fell back to a home-screen shortcut because Chrome saw the same id and skipped WebAPK construction (`chrome://webapks` stayed empty). Omitting the field lets Chrome derive a fresh identifier from `start_url`. The next install on the phone — paired with a full Chrome data clear so the prior WebAPK entry is evicted — should build a real WebAPK with `theme_color: #f5f5f5` baked in.
The previous fix pinned both media queries in viewport.themeColor to `#f5f5f5`, which gave dark-mode users a white status bar. Restore `#1a1a1a` for the dark media query so OS-following users get the right color at first paint. The meta tag only paints at startup though, so a user who toggles dark/light inside the app still sees the original color. Extend the `pi-theme-init` inline script (which already decides whether to add `dark` to <html>) to also rewrite every `<meta name="theme-color">` on the page, then attach a MutationObserver that re-runs the sync whenever the `dark` class toggles — so the status bar follows app state no matter who flips it (toggle button, localStorage, OS theme change).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: PWA support + QR pairing + Tailscale auto-discovery for mobile access
Summary
Make Pi Web a true mobile-first interface. Adds a Progressive Web App shell so a phone can install Pi Web to its home screen, replaces manual password typing with a one-time QR pairing token, and has the desktop launcher auto-discover its Tailscale IP so the QR code points at the right address without any user configuration.
This PR combines three commits that evolved together in a downstream fork:
feat: connect phone via QR + Tailscale— runtime password, pairing tokens, Connect Phone modal, Tailscale-aware launcherfeat(pwa): install prompt, iOS hint, update toast— user-facing UI for installing the PWA and detecting updatesfeat(pwa): manifest, icons, screenshots, SW cache hygiene— PWA infrastructure (manifest, icons, service worker, offline page)Why this exists
The current mobile flow is fragile
Today, the only documented way to reach Pi Web from a phone is:
Three things go wrong:
192.168.1.x, get it wrong, give up.0.0.0.0but the printed URL is still127.0.0.1. If they bind to100.x.x.xthey have to remember which one.The QR code path fixes all three
tailscale ip -4(or falls back to the first non-loopback IPv4) and uses that in the QR code.Why a Progressive Web App and not a native app
pi-webbinary is still the runtime; the PWA is just an additional install surface.What's new
1. PWA infrastructure (
feat(pwa): manifest, icons, screenshots, SW cache hygiene)Files
app/manifest.tsdisplay: "standalone",start_url,scope, shortcutspublic/icons/icon-192.pngpublic/icons/icon-512.pngpublic/icons/icon-512-maskable.pngpublic/icons/shortcut-new.pngpublic/screenshots/desktop-wide.pngscreenshots[]public/screenshots/mobile-narrow.pngpublic/sw.js/api/*public/sw.test.mjssw.jsnotification-click handler and message handlerpublic/offline.htmlManifest contract (
app/manifest.ts)Service worker behaviour (
public/sw.js)pi-web-*caches (handles pnpm→npm migration residue) and precachesPRECACHE_URLS(manifest + icons + offline page).pi-web-*caches, callsclients.claim()./api/*and/sw.jsitself.offline.html./_next/static/*or anything inPRECACHE_URLS) — cache-first.{ type: "SKIP_WAITING" }from the registration helper.2. PWA user-facing UI (
feat(pwa): install prompt, iOS hint, update toast)components/PwaRegistration.tsxnpm startand reloads — cached production chunks otherwise break dev.components/PwaInstallPrompt.tsxbeforeinstallprompt, defers it, surfaces an "Install Pi Web" chip in the corner until the user clicks (orappinstalledfires).components/PwaIosHint.tsxbeforeinstallprompt. Detects iOS Safari and shows "Share → Add to Home Screen" instead.components/PwaUpdateToast.tsxcontrollerchangeand shows a "New version available — reload" toast.components/PwaManifest.test.mjsThe install chip and iOS hint are deliberately minimal — small chip in the corner, not a modal banner — so they don't interfere with the chat UI.
3. QR pairing + Tailscale auto-discovery (
feat: connect phone via QR + Tailscale)Runtime password (
lib/runtime-password.ts)Why a file and not
globalThis:Format: 6 decimal digits, mode 0o600, written atomically (
writeFileSyncto a temp file thenrename). Cold boot wipes the file (intentional — invalidates prior session cookies, this is the "switch device and re-auth" path).Resolution order:
process.env.PI_WEB_PASSWORD(opt-in override) → existing file → generate + persist + return.Note on no in-process cache: even within one process, Next.js 16 may compile
proxy.tsand route files into separate module graphs that don't shareglobalThis. A cached password would diverge between them and Basic Auth would silently fail. The file is the only cross-process source of truth. A 6-byte read from local disk is cheap enough that we just always read.Pairing token flow
New API routes
/api/pair-info{ address, pinRequired, pairingToken, expiresIn }.addressis the Tailnet IP if Tailscale is up, else the first non-loopback IPv4, else127.0.0.1./api/pair-password{ present, source, regeneratable }by default. With?reveal=1ANDisApiRequestAllowed(req)passing, returns the actual PIN. TheisApiRequestAllowedgate blocks passive exfiltration (rogue extensions, malicious pages)./api/pair-password/regenerate/api/pair-tokenspairquery token for api-sessioncookie (30-day expiry).bin/pi-web.jschanges-H 0.0.0.0: runtailscale ip -4. If it succeeds, use the returned IP for the QR code. Iftailscaleis not installed, fall back to the first non-loopback IPv4. If neither works, fall back to127.0.0.1(with a hint that the phone won't reach it).openBrowserWindowbehaviour (auto-open the desktop browser when "Ready" appears in stdout).Connect Phone modal (
components/PairDevice.tsx)<address>/?pair=<token>) plus the 6-digit PIN as a fallback.Test plan
A. PWA install on Chrome Android (desktop view)
http://<lan-ip>:30141/on the desktop.B. PWA install on iOS Safari
http://<lan-ip>:30141/on the phone in Safari.C. QR pairing with Tailscale
100.64.1.2).pi-web --hostname 100.x.x.x # the tailnet IPhttps://100.x.x.x:30141/?pair=<token>D. QR pairing on LAN (no Tailscale)
tailscaleis not installed:tailscale ip -4, gets a non-zero exit code, and falls back to the first non-loopback IPv4 (e.g.192.168.1.42).E. Bare URL fallback (PIN entry)
http://100.x.x.x:30141/(don't use the QR).F. Regeneration
401on the next request.G. PWA update toast
app/→ save.H. Dev mode cache hygiene
npm run start(production server). SW registers.npm run dev./triggersPwaRegistration's dev-mode cleanup: unregisters the production SW, reloads the page.npm run devworks normally (cached production chunks don't break dev).I. Existing flow still works
PI_WEB_PASSWORD='your-password' pi-web --hostname 0.0.0.0→ unchanged behaviour, Basic Auth still applies.Security model
PIN is reachable only via
?reveal=1+ same-originisApiRequestAllowed(req)(inlib/request-security.ts) blocks passive fetches:<img src="/api/pair-password?reveal=1">— blocked (cross-origin).fetch('/api/pair-password?reveal=1')— blocked (noSec-Fetch-Site: same-origin).Pairing tokens are short-lived (~30s)
The QR contains
<address>/?pair=<token>. The token is single-use and expires fast. If the QR is photographed by a third party, the token is useless within seconds.Brute-force risk on the 6-digit PIN
A 6-digit decimal PIN has 10⁶ = 1M combinations. Mitigations:
regenerateon demand.This is acceptable for a developer tool accessed on a trusted tailnet. It is not acceptable for a public-internet deployment — the existing HTTPS-via-reverse-proxy guidance still applies.
Session cookie
pi-sessioncookie is HttpOnly, 30-day expiry, scoped to the origin. Cleared by regenerate.What this PR does NOT change
PI_WEB_PASSWORDusers./api/*authentication beyond the PIN layer.Breaking changes
For users who bind to a non-loopback address without setting
PI_WEB_PASSWORD:pi-sessioncookie.This is a security improvement, but it is a default-behaviour change. It should be called out in the release notes. For users who hit this, the migration path is:
For users who set
PI_WEB_PASSWORD, behaviour is unchanged.Files
41 files changed, ~2178 lines added.
New files (23)
Modified files (18)
Out of scope (deliberately left for follow-up PRs)
notificationclickbut the SW doesn't push proactively. Web Push requires a server-side VAPID setup that should be a separate PR.share_targetfield is not set; receiving shared text/images into Pi Web is a separate feature./foo") is unrelated to mobile access.Reviewer FAQ
Q: Why a 6-digit PIN and not longer?
A: A 6-digit PIN is short enough to type on a phone in under 5 seconds. Brute-force is mitigated by the network layer (Tailscale / LAN, not public internet). The PIN is per-installation and can be regenerated any time. For a developer tool on a trusted tailnet, this is the right tradeoff.
Q: Why PWA and not Electron/Tauri?
A: PWA installs from the browser with one tap — no app store review cycle, no platform-specific binaries. The runtime is still the same
pi-webNode.js binary; the PWA is an additional install surface. A native shell would re-introduce the desktop-client problem this whole project tries to avoid (one codebase per platform, manual distribution).Q: Why a runtime password file and not
globalThis?A: Next.js 16 runs the proxy middleware in a separate worker process from the route handlers.
globalThisis not shared across processes. The file is the smallest cross-process bridge that works.Q: Why is the dev server forced to reload after registering a SW?
A: If a previous
npm startregistered a SW, that SW intercepts/_next/static/*and serves production chunks to the dev page, causing "module factory is not available" errors. ThePwaRegistrationdev-mode branch unregisters the stale SW and reloads.Q: Does this break
PI_WEB_PASSWORD?A: No.
PI_WEB_PASSWORDis the highest-priority password source. If it's set, the auto-PIN is never generated and Basic Auth still works as before.Q: Why is the QR token only 30s?
A: Long enough to scan, short enough that a photographed QR is useless within seconds. Single-use prevents replay.
Q: Why
app/manifest.tsinstead of a staticmanifest.webmanifest?A: Next.js metadata route — generates the manifest at build time with proper headers (
Cache-Control: public, max-age=0, must-revalidate) and TypeScript safety on the schema. The static file approach doesn't get type checking on the manifest shape.Co-Authored-By: Claude Fable 5 noreply@anthropic.com