Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
114 lines
6.5 KiB
Plaintext
114 lines
6.5 KiB
Plaintext
## The first run
|
||
|
||
Every walk and every deploy so far started from Kyle's own phone — signed
|
||
in, library folder set, a config full of his settings. What a phone that
|
||
has just installed the app actually has is different in three ways, and
|
||
only one of them is what you would guess.
|
||
|
||
**The library root is never missing.** `config.rs`'s `download_dir` has an
|
||
`#[cfg(target_os = "android")]` branch that falls back to
|
||
`app_data_dir()/Library` and returns before the desktop's `audio_dir()`
|
||
fallback is reached, so a fresh install has a real, writable, empty library
|
||
root at `/data/user/0/com.soulstream.app/files/Library` — invisible to
|
||
every other app on the phone, including the one that put the music there.
|
||
`pipeline::init` scans it at startup and `library_albums` honestly answers
|
||
`[]`. This is why "no folder yet" cannot be a null check anywhere: the test
|
||
is the PATH, `isPrivateFolder` in `setup.svelte.js`, which You.svelte had
|
||
first and now imports rather than keeping a second copy of.
|
||
|
||
**`setup_done` was already there and the phone never touched it.** It
|
||
round-trips through `get_settings`/`set_settings` like any other field and
|
||
`config::setup_done` unwraps a missing one to `false`, so **every phone in
|
||
existence reads as unstamped**, Kyle's included. Gating the tour on that
|
||
flag alone — which is what the desktop does — would have opened a setup
|
||
sheet on a phone already full of his own music, and then written the flag
|
||
into his config on dismissal.
|
||
|
||
**Nothing is signed in, and everything says so honestly and helpfully
|
||
nowhere.** `connectStored()` returns at once with no account saved, the
|
||
connection stays `offline`, and every stream and every Save ends as a red
|
||
toast with a sentence in it and no way out.
|
||
|
||
So: `src/mobile/setup.svelte.js` is the one store, and there are two
|
||
surfaces.
|
||
|
||
**The tour** is a half sheet (`kind === "setup"` in `HalfSheet.svelte`) —
|
||
one scroll, two cards, a foot. Where your music lives: the current folder,
|
||
a row that opens the storage plugin's folder picker, and the all-files
|
||
permission row beside it once a real folder has been picked. Soulseek: what
|
||
it unlocks, and one row that goes to the sign-in card on Community. **The
|
||
tour never handles a password** — there is no field in it; Kyle signs in
|
||
himself, on the card that already existed. Keeping the private folder and
|
||
staying signed out are both legitimate answers, so the sheet is answerable
|
||
by doing nothing, and the scrim and the back gesture close it like any
|
||
other half sheet.
|
||
|
||
**The boot gate is a three-way AND** — `!setup_done && !slsk.savedUser &&
|
||
!lib.albums.length` — and it is evaluated only after `loadLibrary()` and
|
||
`connectStored()` have both settled (`Promise.all` in `Mobile.svelte`'s
|
||
`onMount`). Read either of those early and they are `[]` and `""` on every
|
||
phone there is. Kyle's phone fails the second and third clause, which is
|
||
the only safe way to ship an auto-opening layer into a pipeline that
|
||
installs onto his device.
|
||
|
||
**The note** (`SetupNote.svelte`) is the fallback for the screens that are
|
||
empty because of the same two things, with the row that fixes each: Home's
|
||
empty state (whose copy still said music "lives in the app's own Library
|
||
folder for now", written before the picker landed in You), Library →
|
||
Albums, Library → **Artists** — which had no empty state at all, just a
|
||
`0 artists · 0 followed` line over nothing — and Search's empty page, where
|
||
"Sign in on Community to play what the library does not hold" was a flat
|
||
sentence and is now the row that does it. A row is drawn only for a thing
|
||
actually missing, and the component renders nothing when both are answered,
|
||
so a phone that is set up never sees it.
|
||
|
||
There are two doors onto the same folder — the tour's row and You's own
|
||
picker — and they tell each other: You re-reads when `setup.folder` moves,
|
||
and its picker calls `loadSetup()` on the way past, or the note on Home
|
||
would go on offering a folder that had already been picked on You. The
|
||
all-files row does the same trick You does with the grant: the system
|
||
screen takes the app away and gives it back, so `requestAccess` re-reads
|
||
the answer on the way back rather than leaving a row up that has been
|
||
answered.
|
||
|
||
**The preview door** is one row in You → This phone: "Set up this phone
|
||
again", `openSetup("preview")`. A preview reads the config and writes
|
||
nothing — `setup.writes`, the number of times the store has stamped
|
||
`setup_done`, stays where it found it. The phone walk drives exactly that
|
||
door on Kyle's phone and asserts the counter at zero on the way in and on
|
||
the way out; it types nothing, picks nothing and clears nothing.
|
||
|
||
The harness hosts the other device. `localStorage.mockFresh`, or `#fresh`
|
||
in the URL (which is what the walk uses — a reload cannot lose a fragment),
|
||
switches `src/lib/mockLibrary.js` to a fresh install: every library
|
||
command answers `[]`, `get_credentials` answers with no account,
|
||
`all_files_access` is not granted, and `mockSettings` is mutated in place
|
||
so the library root is the sandbox path and `setup_done` is false —
|
||
mutated rather than replaced so `set_settings` still works and picking a
|
||
folder in the walk genuinely moves the state the notes read. The
|
||
fresh-install block is the LAST thing in `MOCK_STEPS` because its reload
|
||
wipes every module's state, and it turns the flag off and reloads again at
|
||
the end so that anything appended after it is not silently running against
|
||
an empty device.
|
||
|
||
Two things a reload does NOT clear on its own, both handled in that block
|
||
because it is the first one on the mock leg. The webview's storage comes
|
||
back with the page, and the player writes the session out on `pagehide`,
|
||
which a reload fires — so removing the key up front puts it straight back;
|
||
the block adds a `pagehide` listener of its own, last and therefore run
|
||
last, and a phone with nothing in it comes up with no now-playing bar. And
|
||
the viewport emulation lives on the DevTools session rather than on the
|
||
page, so the re-attach after a reload drops it: the walk keeps what it set
|
||
and hands it back, or every still from there on would be the Chrome
|
||
window's own size at 1×.
|
||
|
||
Two commands the phone can reach had no mock at all until this round —
|
||
`player_control` and `open_external` — found by mapping every `api.js`
|
||
wrapper used under `src/mobile` against the mock (126 wrappers, 124
|
||
mocked). Unmocked they fall through to the real IPC, which on a `file://`
|
||
page rejects: a console warning and a toast, in whatever step happens to
|
||
touch them first. The transport now answers the way the real core does,
|
||
with an event; `resume` cannot restart the position ticks, so the state is
|
||
right and the position stands still.
|
||
|