Mobile
A recommendation for iOS and Android, grounded in how ReviewStage actually works today:
- one self-hosted server per team, reached at a URL the team chooses;
- GitHub OAuth (or a pasted fine-grained token) already completes server-side, and a signed HMAC session cookie is the only credential the browser holds;
- each reviewer connects Claude per user, via PKCE to the genuine
claude setup-tokenflow; - every piece of data the UI shows or changes goes through
/api/*JSON; the HTML shell is static and the React SPA does the rest.
Those four facts make the decision easy: the product is already an API plus a client. A mobile strategy is a question of packaging that client, not of writing a second one.
Recommendation in one paragraph
Section titled “Recommendation in one paragraph”Ship the PWA now (done: manifest, root-scoped service worker, icons, phone-width layout).
When store presence or push notifications become a real ask from a paying team, wrap the same
React build in Capacitor for the App Store and Play Store, add a device-token path to the
server so the wrapper can hold a long-lived credential, and implement push as one more backend of
the notify abstraction. Do not write a React Native app; there is nothing in this UI that
needs native widgets, and a second codebase would immediately lag the web dashboard.
Phase 1 (now): installable PWA
Section titled “Phase 1 (now): installable PWA”What ships. dashboard-ui/public/manifest.webmanifest (standalone display, the app’s dark
palette, 192/512 + maskable icons generated from assets/logo-light.svg by
dashboard-ui/dashboard-ui/scripts/icons.mjs), public/sw.js served from the origin root at /sw.js, an
offline.html fallback, and src/mobile.css, a sheet of media queries for phone widths. The
server exposes /manifest.webmanifest, /sw.js, /offline.html and /icons/* at the root;
everything else in bin/static/ is unchanged.
Caching policy. Cache-first for /static/* and /icons/*; network-first for navigations
with the cached shell, then offline.html, as fallbacks; /api/* is never cached. API
responses are per-user, HMAC-signed action tokens expire in 30 minutes, and run status changes
between two clicks, so a cached API response would only ever be wrong. The worker is registered
from main.tsx in production bundles only, so pnpm dev never serves a stale shell.
Why it is enough for most teams. Installable from the browser on both platforms, zero store friction, one codebase, and the whole sign-in story (GitHub token or OAuth, Claude connect) is unchanged because the installed app is the same origin with the same cookie. Team members already open the dashboard from a Slack card; on a phone that card now opens an app-shaped window.
Limits, honestly.
- iOS web push exists only for the home-screen app, since iOS 16.4. A Safari tab gets nothing. Android Chrome delivers web push to tabs and installed apps alike.
- No background sync worth relying on. The queue refreshes when the app is foregrounded.
- Web push is shipped (Phase 2b below): a VAPID key pair on the server, a per-device
subscription store, and the
pushbackend ofnotify.sh. Turn it on per device from Settings → Notifications on this device; on iOS only the home-screen app receives it. - iOS Safari evicts storage for installed web apps that go unused for weeks; the session cookie may need a fresh sign-in after a long gap. Acceptable for a work tool.
Effort. Done in this change. Ongoing cost is near zero: the icon step runs inside
pnpm build, the e2e suite checks the served content types and phone-width overflow.
From the desktop app
Section titled “From the desktop app”npx reviewstage gets a phone onto the same install without Tailscale or a reverse proxy.
Enable phone access (in the app menu on macOS, the File menu elsewhere, and the dock menu)
starts a bundled Cloudflare quick tunnel — cloudflared, pinned by version and SHA-256 in
desktop/tools.json like gh and jq — and shows a QR code as soon as the
https://*.trycloudflare.com address exists; whether the edge answers /health yet is shown
as a status under it. The QR encodes a pairing URL: the app mints a single-use code
(POST /api/pair, loopback and session required, valid 30 minutes) for the person signed in on
the Mac, and GET /pair/<code> on the phone sets that person’s session cookie and redirects
to the queue — no second GitHub login. An expired code lands on the login page with a line
saying to reopen Show phone access code… on the Mac. Then Share → Add to Home Screen and
turn on notifications in Settings: HTTPS is real, so the service worker, the home-screen install
and web push all work as on any other origin.
The tunnel lives as long as the app does: cloudflared reconnects on its own after a dropped
connection or sleep and the address does not change. If the cloudflared child exits while
phone access is on, the app restarts it once with a new address, mints a new code, updates the
phone window and shows a system notification (“Phone address changed — rescan the code”); a
second exit within 5 minutes stops it and the menu shows phone access as disabled. The app
tells the server the public address (POST /api/public-url) so notification links open on the
phone, and resets it to loopback when you disable phone access or quit, which is also when the
tunnel ends. The address is public while it is on; every action still needs your sign-in, as
described in Security.
Phase 2: Capacitor wrapper for store presence and native push
Section titled “Phase 2: Capacitor wrapper for store presence and native push”What it is. Capacitor puts the same bin/static build inside
a native WebView shell with a small plugin bridge. The web app does not change; it gains
window.Capacitor and a handful of plugins.
Why Capacitor over React Native or Expo here.
- The dashboard is forms and lists: a queue, cards with checkboxes, markdown editors, a settings page. Nothing needs native scrolling physics, maps, camera or heavy animation. The WebView renders the existing app at full fidelity.
- 100% reuse. One team, one codebase, one release train. A React Native app would be a second UI over the same API, and every feature (stack review, QA guides, Insights, the command palette) would land on web first and mobile later, or never.
- The server is the product. What a mobile client adds is presence (an icon, a badge, a notification), not capability. Capacitor gives exactly that at the lowest cost.
When React Native / Expo would win instead. If the mobile experience needed to diverge from the web (a swipe-to-triage queue, offline review reading with local storage of diffs, native diff rendering), or if the web dashboard were being retired in favour of mobile-first, or if the team already had RN expertise and a design system in it. None of those hold today.
Native pieces to add.
| Piece | Plugin | Notes |
|---|---|---|
| Push | @capacitor/push-notifications | FCM on Android, APNs on iOS. The device registers its token with the server (see Push, below). |
| Biometric unlock | capacitor-native-biometric or the OS keychain via @capacitor/preferences + Face ID prompt | Guards the stored device token, not the GitHub token, which never leaves the server. |
| In-app browser for sign-in | @capacitor/browser | ASWebAuthenticationSession on iOS, Custom Tabs on Android. Cookies of the system browser are available, so an existing GitHub session completes OAuth in one tap. |
| Deep links | @capacitor/app + associated domains / App Links | reviewstage://pr?repo=owner/name&pr=123 opens the PR page; the same path as an https://<server>/pr?... link is registered as a universal link, so a Slack card works whether or not the app is installed. If the app is not installed the link is just the web URL and the PWA (or browser) opens it. |
| Badge count | @capawesome/capacitor-badge | Mirrors the “to review” count from /api/queue. |
Multi-server. Every team self-hosts, so the app cannot hard-code a URL. On first launch it
asks for the server URL (with https:// assumed and /health probed before saving) and stores a
list of servers, each with its own device token. A picker in Settings switches between them;
deep links carry the host so they select the right one. The web build reads its base URL from
window.location today; under Capacitor it reads the selected server instead, which is a small
change to src/api.ts (one base() function) and nothing else.
Effort. Roughly two to three weeks for one engineer who knows the codebase, including the Apple and Google developer-account plumbing, plus the server changes below, which are about two days of Python and a Settings → Devices page. Store review is the long pole: budget a week of calendar time for the first submission.
Auth for a mobile client: the device-token flow
Section titled “Auth for a mobile client: the device-token flow”The constraint is the one that defines the product: the app must sign in with GitHub once and reuse the same server-side token for posting, as the signed-in person. The GitHub token never leaves the server; the client only ever proves who it is.
Today that proof is a session cookie set by /oauth/callback (or by the token sign-in form).
Cookies work in a WebView but are awkward across an in-app browser boundary and are cleared by
the OS more readily than a keychain item, so the native wrapper needs a bearer credential it can
keep in the keychain behind biometrics. Hence device tokens.
Flow.
- App opens
https://<server>/login?device=1in an in-app browser (ASWebAuthenticationSession/ Custom Tabs). - The server runs the existing GitHub OAuth (or the token paste) exactly as for the web and sets the normal session cookie.
- With that session, the in-app page calls
POST /api/device-token(name from the OS, e.g. “Wimukthi’s iPhone”). The server mints an opaque token, stores only its SHA-256 hash inusers.json, and returns the plaintext once. - The page hands the token back to the app through the custom scheme
(
reviewstage://auth?token=...&server=...); the in-app browser closes. The app stores it in the keychain, optionally gated by Face ID / fingerprint. - Every
/api/*call from the app carriesAuthorization: Bearer <token>. The server accepts bearer or cookie; nothing else about the API changes. - Settings → Devices lists the user’s devices (name, created, last seen) with a Revoke button.
Revoking deletes the hash; the next call from that device gets
401and the app returns to step 1.
Server changes (implemented — bin/rs_devices.py, bearer_user() and the /api/devices*
handlers in bin/server.py; deviations from the original spec are listed after the list):
bearer_user(headers) -> login | Nonein the auth layer next tosession_user(): readAuthorization: Bearer <tok>, hash it, look upusers[login]["devices"][hash], refuse if the user has been removed fromusers.json, bumplast_seen(rate-limited to once a minute to keepusers.jsonwrites rare).api_get/api_postcallsession_user(h) or bearer_user(h). HMAC action tokens are unchanged: the app fetches them from the same/api/prpayload the web does.POST /api/device-token— session-cookie auth only (a bearer may not mint another bearer). Body{ "name": str }. Response{ "token": str, "id": str, "created": int }. Token is 32 random bytes, base64url; storage isusers[login]["devices"] = { sha256_hex: { "id", "name", "created", "last_seen" } }. Cap at 10 devices per user; oldest is evicted with a warning in the response.GET /api/devices— list for the signed-in user:[{ id, name, created, last_seen, current: bool }](never the hash or token).POST /api/devices/revoke—{ "id": str }, cookie or bearer auth; a device may revoke itself. Signing out on the web (/logout) does not revoke devices; a “Sign out everywhere” button in Settings revokes all.- Token lifetime: 180 days since last use, sliding; the poller’s nightly pass drops expired
hashes. Tokens are never logged;
users.jsonstayschmod 600as today. /login?device=1— the existing login page with one extra step after success: callPOST /api/device-tokenand redirect toreviewstage://auth?.... The page must show the server URL and the GitHub login it is about to bind, so a phished user sees the mismatch.- Threat notes: a stolen device token is as powerful as a stolen session cookie (post and
approve as that user) and no more; it cannot read the GitHub token or the Claude token. It is
revocable per device, which the cookie is not. The
DRY_RUNgate applies to bearer calls too.
Deviations in the implementation (each deliberate; the rest is as specified):
- Pairing goes through an interstitial,
/device./login?device=1keeps the flag through either sign-in path and lands on a server-rendered page that shows the server URL and the GitHub login being bound, with an editable device name (default from?name=or the User-Agent) and an Open the app button. The token is minted on that click, never as a side effect of the login redirect, and the page then navigates toreviewstage://auth?token=…&server=…; if the scheme does not open, the same link and the token itself are shown once for the CLI. The OAuth callback skips the first-run welcome detour for this path. GET /api/devicesreturns an object,{ "devices": [...], "max": 10, "ttl_days": 180 }, not a bare array, so the UI can state the cap and lifetime. Rows are newest first.POST /api/device-tokenalso returnsname(after trimming to 60 chars) and, when the cap evicted something, a human-readablewarning. A bearer calling it gets 403, a session gets the token.- Eviction is least-recently-used (by
last_seen, thencreated), not strictly oldest created: an old device still in daily use survives a burst of new pairings. /api/menever returns 401. As on the web, an unknown or revoked bearer gets200 {"authed": false}there; every other/api/*route returns401as specified./api/mereportsauth(cookie|bearer) andlogin_via(oauth|pat) so the Settings card can disable minting when the current session is itself a bearer.- The nightly prune is one poller step (
pr-watch.sh→python3 rs_devices.py prune), guarded by a per-day stamp, and it rewritesusers.jsononly when something actually expired, so the poller almost never writes the file the server owns.
Push is implemented once, server-side, as the push backend of bin/notify.sh (beside
slack, discord, generic). When pr-watch.sh or the webhook finds a review request,
notify_card fans out to every backend; push looks up the requested reviewer’s subscribed
devices and sends the title, the PR title and the review page’s URL. Shipped for the installed
PWA (web push); the FCM/APNs leg for a Capacitor wrapper is still roadmap.
- Server.
bin/rs_push.py: RFC 8291 content encryption (ECDH P-256 + HKDF + AES-128-GCM, oneaes128gcmrecord), RFC 8292 VAPID (ES256 JWT,aud= push-service origin, 12 h expiry), RFC 8030 delivery (TTL,Urgency,Topicderived from the tag so repeats for one PR collapse).cryptographyis the one pip package in the image for this; HKDF is stdlib. - Keys.
$ROOT/push_vapid.json(0600), generated on first use, or pinned withVAPID_PRIVATE_KEY/VAPID_PUBLIC_KEY;VAPID_SUBJECTis the contact claim. - Subscriptions.
$ROOT/push_subs.json(0600, fcntl-locked likeusers.json): one row per browser per user —{login, endpoint, keys, device, added, ua}. A404/410from the push service removes the row. Ten per user; the oldest is evicted. - Routes (session cookie or bearer device token):
GET /api/push/key,POST /api/push/subscribe{subscription, device},POST /api/push/unsubscribe{endpoint | id},GET /api/push/devices(endpoints masked),POST /api/push/test(rate-limited like sign-in; the caller’s own devices only). - Client.
dashboard-ui/src/push.ts(isSupported,permission,subscribe,unsubscribe,usePush) and thePushDevicespanel;public/sw.jsgainedpushandnotificationclickhandlers (the caching policy is unchanged). - Payload.
{title, body, url, tag}— the title, the PR title and a URL. Never the finding text: it goes through a third-party relay. - iOS caveat. Web push only reaches the home-screen PWA; the native wrapper is the way to reach every iPhone user. The panel shows an install hint on iOS Safari until then.
Roadmap placement
Section titled “Roadmap placement”| Phase | Items | Status | Effort |
|---|---|---|---|
| 1 — PWA | Manifest, icons, root-scoped service worker, offline page, mobile.css, e2e checks | Shipped in this change | Done; ongoing cost nil |
| 2a — Device tokens | bearer_user, /api/device-token, /api/devices, /api/devices/revoke, /device pairing page, Settings → Devices | Shipped | Done |
| 2b — Push | push notify backend, web push (VAPID) for the PWA, per-device subscriptions, Settings panel | Shipped (web push); FCM/APNs waits for the wrapper | Done |
| 2c — Capacitor apps | Wrapper, in-app-browser sign-in, biometrics, deep links, multi-server picker, store listings | Roadmap (P2) | 2–3 weeks + store review |
The order was deliberate: device tokens unblock both push registration and the wrapper, and are useful on their own (a CLI or a second browser could use one). Push before the wrapper, because the PWA on Android and the iOS home-screen app can receive it already. The wrapper last, when a team asks for the store icon or for iPhone push that reaches a Safari-tab user.
MIT licensed · Built on Claude Code