Skip to content

feat: PWA support + QR pairing + Tailscale auto-discovery for mobile access - #551

Open
haotianliangye wants to merge 28 commits into
agegr:mainfrom
haotianliangye:feat/pwa-qr-tailscale
Open

feat: PWA support + QR pairing + Tailscale auto-discovery for mobile access#551
haotianliangye wants to merge 28 commits into
agegr:mainfrom
haotianliangye:feat/pwa-qr-tailscale

Conversation

@haotianliangye

Copy link
Copy Markdown

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:

  1. feat: connect phone via QR + Tailscale — runtime password, pairing tokens, Connect Phone modal, Tailscale-aware launcher
  2. feat(pwa): install prompt, iOS hint, update toast — user-facing UI for installing the PWA and detecting updates
  3. feat(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:

PI_WEB_PASSWORD='your-password-here' pi-web --hostname 0.0.0.0
# Then on the phone, type the LAN IP, type the password

Three things go wrong:

  • "What's my LAN IP?" — users guess 192.168.1.x, get it wrong, give up.
  • "What's the password?" — multi-tap typing on a phone keyboard for a complex password is a chore.
  • Tailscale users have it worse — the launcher binds to 0.0.0.0 but the printed URL is still 127.0.0.1. If they bind to 100.x.x.x they have to remember which one.

The QR code path fixes all three

  • No more guessing IPs — the QR encodes the right address.
  • No more password typing on the phone — the QR contains a one-time pairing token that exchanges for a 30-day session cookie.
  • Tailscale auto-resolved — the launcher runs 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

  • A PWA installs from the browser with one tap — no app store review cycle, no platform-specific binaries.
  • The same pi-web binary is still the runtime; the PWA is just an additional install surface.
  • A native app would have to keep up with four platform-specific codebases (iOS/Android/macOS/Windows) and would re-introduce the desktop-client problem this whole project tries to avoid.

What's new

1. PWA infrastructure (feat(pwa): manifest, icons, screenshots, SW cache hygiene)

Files

File Purpose
app/manifest.ts Web App Manifest: name, icons, screenshots, display: "standalone", start_url, scope, shortcuts
public/icons/icon-192.png PWA icon (any size)
public/icons/icon-512.png PWA icon (high-res)
public/icons/icon-512-maskable.png Maskable variant for Android adaptive icons
public/icons/shortcut-new.png Icon used by the "New session" home-screen shortcut
public/screenshots/desktop-wide.png Wide screenshot referenced in screenshots[]
public/screenshots/mobile-narrow.png Narrow screenshot for mobile install banners
public/sw.js Service worker — precache + cache-first for static, never for /api/*
public/sw.test.mjs Tests for sw.js notification-click handler and message handler
public/offline.html Fallback page when the user goes offline mid-session

Manifest contract (app/manifest.ts)

{
  id: "/",
  name: "Pi Web",
  short_name: "Pi Web",
  start_url: "/",
  scope: "/",
  display: "standalone",            // ← key: no browser chrome after install
  orientation: "any",
  background_color: "#1a1a1a",
  theme_color: "#1a1a1a",
  icons: [
    { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
    { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
    { src: "/icons/icon-512-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" }
  ],
  screenshots: [...],
  shortcuts: [{ name: "New session", url: "/?action=new", ... }]
}

Service worker behaviour (public/sw.js)

  • Install: clears all pi-web-* caches (handles pnpm→npm migration residue) and precaches PRECACHE_URLS (manifest + icons + offline page).
  • Activate: deletes old pi-web-* caches, calls clients.claim().
  • Fetch:
    • Always bypasses /api/* and /sw.js itself.
    • Navigate requests — try network, fall back to offline.html.
    • Static assets (/_next/static/* or anything in PRECACHE_URLS) — cache-first.
  • notificationclick — focuses an existing client at the notification's URL, or opens a new window. Cross-origin targets are rejected.
  • message — handles { type: "SKIP_WAITING" } from the registration helper.

2. PWA user-facing UI (feat(pwa): install prompt, iOS hint, update toast)

File Purpose
components/PwaRegistration.tsx Registers the SW on mount. In dev mode, unregisters any stale SW from a previous npm start and reloads — cached production chunks otherwise break dev.
components/PwaInstallPrompt.tsx Captures beforeinstallprompt, defers it, surfaces an "Install Pi Web" chip in the corner until the user clicks (or appinstalled fires).
components/PwaIosHint.tsx iOS Safari doesn't fire beforeinstallprompt. Detects iOS Safari and shows "Share → Add to Home Screen" instead.
components/PwaUpdateToast.tsx Listens for controllerchange and shows a "New version available — reload" toast.
components/PwaManifest.test.mjs Tests for manifest shape

The 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:

Next.js runs the proxy middleware in a separate worker process from the route handlers. globalThis is not shared across processes. The smallest cross-process bridge is a file.

Format: 6 decimal digits, mode 0o600, written atomically (writeFileSync to a temp file then rename). 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.ts and route files into separate module graphs that don't share globalThis. 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

desktop                              phone
  │                                    │
  ├─ POST /api/pair-info (operator)    │
  │   → { address, pinRequired,        │
  │       pairingToken, expiresIn:30s }│
  │                                    │
  │   ┌─── QR encodes:                 │
  │   │   <address>/?pair=<token>      │
  │   └─── operator scans with phone ───┤
  │                                    │
  │   ┌─── phone browser opens URL ────┤
  │   │   GET /?pair=<token>           │
  │   │   → handler validates token    │
  │   │   → sets 30-day cookie         │
  │   │   → redirects to /             │
  │   └─── session established ────────┤
  │                                    │
  │   (PIN still works as fallback:    │
  │    if phone lands via bare URL,    │
  │    the Connect Phone modal shows   │
  │    the 6-digit PIN; user types it) │

New API routes

Route Method Purpose
/api/pair-info GET Returns { address, pinRequired, pairingToken, expiresIn }. address is the Tailnet IP if Tailscale is up, else the first non-loopback IPv4, else 127.0.0.1.
/api/pair-password GET Returns { present, source, regeneratable } by default. With ?reveal=1 AND isApiRequestAllowed(req) passing, returns the actual PIN. The isApiRequestAllowed gate blocks passive exfiltration (rogue extensions, malicious pages).
/api/pair-password/regenerate POST Operator-triggered PIN rotation. Invalidates existing session cookies. The "switch device and re-auth" path.
/api/pair-tokens GET (or POST) Exchanges a one-time pair query token for a pi-session cookie (30-day expiry).

bin/pi-web.js changes

  • On startup with -H 0.0.0.0: run tailscale ip -4. If it succeeds, use the returned IP for the QR code. If tailscale is not installed, fall back to the first non-loopback IPv4. If neither works, fall back to 127.0.0.1 (with a hint that the phone won't reach it).
  • On cold boot: delete the runtime password file. Same rationale — invalidate prior cookies.
  • Keep the existing openBrowserWindow behaviour (auto-open the desktop browser when "Ready" appears in stdout).

Connect Phone modal (components/PairDevice.tsx)

  • Top-bar icon → opens the modal.
  • Modal shows the QR code (encodes <address>/?pair=<token>) plus the 6-digit PIN as a fallback.
  • After successful pairing, the desktop session lives in a 30-day cookie.
  • "Regenerate" button rotates the PIN and invalidates existing cookies.

Test plan

All commands below assume npm install && npm run dev:lan (or npm run build && npm run start:lan for production-behaviour tests).

A. PWA install on Chrome Android (desktop view)

  1. Open http://<lan-ip>:30141/ on the desktop.
  2. After a few seconds, an "Install Pi Web" chip appears in the bottom corner.
  3. Click it → Chrome shows the install prompt → accept → app installs to the launcher.
  4. Open from the launcher → app opens standalone (no address bar, no tab UI).

B. PWA install on iOS Safari

  1. Open http://<lan-ip>:30141/ on the phone in Safari.
  2. The "iOS hint" chip appears in the corner (instead of the install chip).
  3. Tap Share → Add to Home Screen → app installs.
  4. Open from the home screen → app opens standalone.

C. QR pairing with Tailscale

  1. On the desktop, ensure Tailscale is running and you have a tailnet address (e.g. 100.64.1.2).
  2. Start the server bound to the tailnet IP:
    pi-web --hostname 100.x.x.x        # the tailnet IP
  3. Click the "Connect Phone" icon in the top bar.
  4. The modal shows:
    • A QR code encoding https://100.x.x.x:30141/?pair=<token>
    • The 6-digit PIN as a fallback
  5. On the phone, open the Camera app, point at the QR.
  6. Phone lands directly on the desktop session — no password typed.

D. QR pairing on LAN (no Tailscale)

  1. Same flow as C, but tailscale is not installed:
    pi-web --hostname 0.0.0.0
  2. The launcher runs tailscale ip -4, gets a non-zero exit code, and falls back to the first non-loopback IPv4 (e.g. 192.168.1.42).
  3. QR encodes that LAN IP.

E. Bare URL fallback (PIN entry)

  1. With Tailscale up, start the server bound to the tailnet IP.
  2. On the phone, manually open the URL http://100.x.x.x:30141/ (don't use the QR).
  3. The Connect Phone modal opens on the phone, prompting for the 6-digit PIN.
  4. Type the PIN displayed on the desktop → phone session authenticated, cookie set for 30 days.

F. Regeneration

  1. On the desktop, click "Regenerate" in the Pair Device modal.
  2. The PIN rotates.
  3. Any phone with a 30-day cookie gets 401 on the next request.
  4. Phone re-prompts for the new PIN.

G. PWA update toast

  1. While the dev server is running, edit any file under app/ → save.
  2. (For production: deploy a new build.)
  3. The service worker installs the new version → "New version available — reload" toast appears on the desktop.
  4. Click reload → app loads the new version.

H. Dev mode cache hygiene

  1. Start npm run start (production server). SW registers.
  2. Stop it, start npm run dev.
  3. First GET / triggers PwaRegistration's dev-mode cleanup: unregisters the production SW, reloads the page.
  4. After reload, npm run dev works 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-origin

isApiRequestAllowed(req) (in lib/request-security.ts) blocks passive fetches:

  • A malicious page embedding <img src="/api/pair-password?reveal=1"> — blocked (cross-origin).
  • A rogue browser extension calling fetch('/api/pair-password?reveal=1') — blocked (no Sec-Fetch-Site: same-origin).
  • The in-app Connect Phone modal — allowed (same-origin, top-level navigation).

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:

  • The server is reached via Tailscale (or LAN), which is its own authentication layer — the network is closed to the public internet by default.
  • Each wrong attempt is rate-limited (existing Basic Auth rate limiting applies).
  • The PIN is auto-rotated by regenerate on 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-session cookie is HttpOnly, 30-day expiry, scoped to the origin. Cleared by regenerate.

What this PR does NOT change

  • TLS / Basic Auth behaviour for PI_WEB_PASSWORD users.
  • /api/* authentication beyond the PIN layer.
  • Proxy middleware behaviour for non-mobile clients.

Breaking changes

For users who bind to a non-loopback address without setting PI_WEB_PASSWORD:

  • Before: server was reachable with no auth at all.
  • After: server requires the PIN (displayed in the Connect Phone modal) or a valid pi-session cookie.

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:

  • Open the desktop UI → top bar → "Connect Phone" → modal shows the PIN.
  • On the phone, either scan the QR or type the PIN.

For users who set PI_WEB_PASSWORD, behaviour is unchanged.


Files

41 files changed, ~2178 lines added.

New files (23)

app/api/pair-info/route.ts
app/api/pair-password/route.ts
app/api/pair-password/regenerate/route.ts
app/api/pair-tokens/route.ts
app/manifest.ts (already exists upstream; PR modifies the manifest shape)
components/PairDevice.tsx
components/PwaInstallPrompt.tsx
components/PwaInstallPrompt.test.mjs
components/PwaIosHint.tsx
components/PwaIosHint.test.mjs
components/PwaManifest.test.mjs
components/PwaRegistration.tsx
components/PwaUpdateToast.tsx
components/PwaUpdateToast.test.mjs
lib/pair-tokens.ts
lib/runtime-password.ts
lib/session-store.ts
lib/session-store.test.mjs
public/icons/icon-192.png
public/icons/icon-512.png
public/icons/icon-512-maskable.png
public/icons/shortcut-new.png
public/offline.html
public/screenshots/desktop-wide.png
public/screenshots/mobile-narrow.png
public/sw.js (already exists upstream; PR replaces with the full version)
public/sw.test.mjs

Modified files (18)

.gitignore
README.md
README.zh-CN.md
README.ja.md
README.ru.md
app/globals.css
app/layout.tsx
bin/pi-web-options.js
bin/pi-web.js
components/AppShell.tsx
lib/i18n/messages/en.ts
lib/i18n/messages/zh-CN.ts
lib/web-auth.ts
next.config.ts
package.json
proxy.ts

Out of scope (deliberately left for follow-up PRs)

  • Push notifications for completed sessions. The SW supports notificationclick but the SW doesn't push proactively. Web Push requires a server-side VAPID setup that should be a separate PR.
  • Native share-target integration. The manifest's share_target field is not set; receiving shared text/images into Pi Web is a separate feature.
  • Tailscale ACL scoping per session. Currently the server trusts the Tailscale layer entirely. Per-session ACLs (e.g. "this session can only read files in /foo") is unrelated to mobile access.
  • App-store-grade icon set. The current icons are minimal placeholders. A designer-grade icon set is a separate concern.
  • Offline write capability. The offline page only serves a fallback; you can't read sessions offline. That's the correct behaviour for an agent-driven interface, but if the requirement changes, the SW has hooks for it.

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-web Node.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. globalThis is 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 start registered a SW, that SW intercepts /_next/static/* and serves production chunks to the dev page, causing "module factory is not available" errors. The PwaRegistration dev-mode branch unregisters the stale SW and reloads.

Q: Does this break PI_WEB_PASSWORD?
A: No. PI_WEB_PASSWORD is 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.ts instead of a static manifest.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

haoti and others added 28 commits August 14, 2026 21:29
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant