proposals/2026-07-28-app-review-demo.md
Rama master · réplica de lectura del repositorio original.
# A demo mode, so App Review can actually use the app
**Goal** — give an App Store reviewer (and a curious first-run user) a working
PearTune without a server, so the app is not rejected for being unusable.
**Tier** — **T2.** App-only. No wire change, no host change, no change to grants,
pairing or the security boundary. It earns T2 on shipping audio in the bundle and
adding a first-run path that bypasses onboarding.
---
## Why this is a release blocker and not a nicety
PearTune has no account and no cloud. A reviewer installs it, opens it, and is
asked to scan a pairing code from a dashboard on a machine they do not own. There
is nothing to log into and nothing to tap. That is
**Guideline 2.1, App Completeness** — the most common rejection reason there is —
and it is a fair reading: from where they sit, the app does nothing.
Play has the same problem in a milder form, and a rejection there costs less.
### The obvious fix does not work, and this is the load-bearing finding
"Put a pairing link in the review notes" is what everyone reaches for. It cannot
work here, for two reasons in the code rather than two reasons in principle:
- `PAIR_TTL_MS` is **5 minutes** (`protocol/constants.js`).
- The window **closes on the first successful pair** (`host/pair.js`,
`this.close('paired')`).
So a link is both short-lived **and** single-use. A reviewer opening the app a day
later has nothing. Two reviewers, or one reviewer retrying after a rejection, and
the second gets nothing even inside the window. There is no version of "paste a
link into App Store Connect" that survives contact with an unattended reviewer.
### And the workaround for THAT is worse than the problem
The way to make a link work would be a **standing invite**: long-lived,
multi-use. That is not a small flag. `host/pair.js` says what the window is for:
> The window IS the security boundary for a first pair: there is nothing yet to
> check a newcomer against, so trust reduces to "the operator opened a session
> just now, and this device holds the token from that session".
A never-expiring, unlimited-use invite is precisely the negation of that
sentence, and the code path would exist in **every** host — including the ones
strangers run for their friends — to solve a problem in Apple's review queue.
That trade is not worth making. **Rejected, deliberately, and recorded here so it
is not re-proposed as an easy win.**
---
## What to build instead
**A "Try it without a server" path on the onboarding intro card.** It plays a
handful of tracks shipped inside the app. No pairing, no host, no network, no
grant. A reviewer can browse, play, pause, seek, use the lock screen and see the
player skins — every user-facing surface except pairing itself, which is
demonstrated in the video.
It is not a review trick. Someone who installs PearTune before setting up a
server currently sees a wall; this gives them the app working in their hand while
they decide whether to run a library. That is the honest argument for building
it, and the reason it should stay in the product afterwards rather than being
stripped before release.
### Shape
- Demo tracks live in the bundle as ordinary assets. The audio shim already
serves from local disk for cached tracks; the demo library should reuse that
path rather than inventing a second player route.
- The demo library is **read-only and clearly labelled**. It must never look like
a paired library: no revoke, no requests, no favourites syncing anywhere, and
the library switcher should name it something unmistakable ("Demo music").
- Leaving demo mode is a first-class action, not a reinstall. Pairing a real
library from inside demo mode should just work, and should retire the demo
library from the blend at that point.
- Nothing about demo mode may touch the grant store, the identity keypair, or the
pairing window.
### The constraint that decides the music
Every bundled track must be licensed for redistribution **inside a shipped
binary**: CC0, CC-BY, or public domain, with attribution carried in About where
CC-BY requires it. Never Tim's own collection, and never "probably fine".
Sourcing the music is a real task, not a footnote — budget for it.
Three to five tracks is enough to browse an album and hear gapless playback. At
~4 MB each that is 12–20 MB on the download, which is acceptable for a music app.
---
## Also required, regardless
**A demo video in the review notes**, showing the full flow end to end: install
the host, open the dashboard, pair a phone, browse, play, revoke. Apple accepts a
video for apps that need hardware or a service the reviewer cannot have, and it
costs about an hour. It is the fallback if the reviewer never finds the demo
path, and it is cheap enough that there is no reason to skip it even once demo
mode ships.
**Review notes that say the quiet part plainly**: there is no account by design,
the app plays music from a server the user runs, and here is the demo path plus
the video. Notes alone are not sufficient — but omitting them wastes the one
place Apple lets you explain yourself.
---
## Open questions
1. **Which music.** Needs actual sourcing and licence verification, with the
licence recorded in the repo next to the files.
2. **Where the demo library appears in the blend.** Simplest is: only when nothing
real is paired. Worth checking against the merged-library code before assuming.
3. **Whether Play needs the same thing.** Probably yes eventually; the rejection
risk is lower, so it can follow.
4. **Whether demo mode should be reachable after pairing** (a "show me the demo
again" affordance) or is strictly a first-run state.
## Verify
Beyond `npm run verify`: install on a device with **no** pairings and confirm the
demo path plays without any network at all — airplane mode is the honest test,
because it proves no host is involved.
---
## Implementation plan (traced against the code, 2026-07-28)
The music is sourced and in `assets/demo-music/` (five CC0 tracks + `cover.jpg` +
`manifest.json` generated from the real tags). What follows is the design settled by
reading the actual paths, so the build does not start with archaeology.
### The shape to copy
Every browse method in `src/bare.js` already has the same two-branch form:
```js
if (mergedMode()) { ...serve from the merged index... }
await ensureConnected(); ...ask the host...
```
Demo mode is a THIRD branch, taken first, in `tracks`, `albums`, `album`, `artists`
and `search`. That keeps it inside the existing structure instead of beside it.
**Considered and rejected: a fake local "client".** Making demo mode a single-host
mode backed by an in-process client would need no browse changes at all, which is
tempting - but `client/index.js` exposes ~20 methods (favourites, resume, counts,
playlists, identity, avatar, leave) and most are meaningless with no host. Twenty
stubs is a wider surface than five branches, and every one is a thing that can lie.
### Playback needs no new serving code
`worklet/shim.js` already serves a complete local file straight from disk:
```js
if (cache && cache.has(trackId) && leaseOk()) return serveFromCache(trackId, req, res)
```
So installing the demo tracks as **pinned entries in the audio cache** makes playback
work through the existing path - `has` / `get` / `readStream` / range requests /
backpressure all come free, and `pinned` means the LRU never evicts them.
Two small changes that this needs, and they are the only edits to `shim.js`:
1. `leaseOk()` currently takes no argument and gates on host authorization. It must
become `leaseOk(trackId)` and return true for demo ids - a demo track has no host
and therefore no lease that could be fresh.
2. Cover art: install `cover.jpg` into the art store under the demo album's coverId,
so `serveArt` finds it the same way.
### Getting the files onto disk
The bare bundle is already loaded from an asset this way in `app/index.tsx`
(`Asset.fromModule` → `downloadAsync` → `localUri`). The five MP3s and the cover
follow exactly that pattern, and the worklet copies them into the cache dir on demo
activation. Cost: ~18 MB duplicated out of the bundle into app storage. Acceptable,
and it buys the whole existing playback path.
### Order to build it
1. `worklet/demo.js` - manifest → catalog (albums/artists/tracks in the shapes the UI
already consumes) + the cache/art install. Pure and unit-testable with no device.
2. `src/bare.js` - `demoMode()`, the five browse branches, `urlFor`, and
`enableDemo` / `disableDemo` IPC.
3. `worklet/shim.js` - the two changes above.
4. `app/index.tsx` - resolve the assets, hand the paths to the worklet.
5. `src/ui/App.jsx` - "Try it without a server" on the intro card, the demo library
labelled unmistakably, and pairing a real library retires it.
### The verify that actually proves it
Airplane mode, on a device with no pairings. Anything less does not prove the demo is
independent of a host.