android: the first run — a folder to keep music in, and an account to reach the network

Nobody had seen this app on a phone that has just installed it. On Android
the library root is never missing — config.rs falls back to the app's own
sandbox, a real folder nothing else can see — so "no folder yet" cannot be
a null check, and setup_done has never been written by the phone, so every
phone in existence reads as unstamped, Kyle's included. The tour therefore
opens on three clauses, not one: no flag, no saved account, and no records,
read only after the library and the credentials have both answered. Two
cards, skippable, and no password anywhere in it — the Soulseek step goes
to the card on Community and stops.

Every screen that is empty because of those two things now says so with the
row that fixes it: Home (whose copy predated the folder picker), Library →
Albums, Library → Artists, which had no empty state at all, and Search,
where a flat sentence about signing in became the row that does it. A row
goes away because the config moved, so the two doors onto the same folder
tell each other — You re-reads when the tour moves it, the tour's store is
told when You's own picker does — and the all-files row re-reads the grant
when the system screen hands the app back, the way You already did.

You → This phone opens the tour again as a preview that reads the config
and writes nothing, which is the door the phone walk drives on Kyle's own
device. The mock can answer as a fresh install now — an empty database, no
account, no permission, the sandbox root — so the whole thing is walked end
to end, and two commands that had no mock at all (player_control,
open_external) got one on the way past. It is the mock walk's first reload,
which turned up two things a reload does not clear by itself: the player
writes the session back on pagehide, so the fresh block clears it from a
pagehide listener of its own, and the viewport emulation lives on the
DevTools session, so the harness hands it back after the re-attach.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:21:37 -05:00
1 parent 46cf6a98fe
commit 95c2bfeeba
13 files changed
+633 -14

No files matched your search

+131 -2
View File
@@ -473,6 +473,119 @@ Not on the phone yet: the playlist-file door and export — both want a file
picker the storage plugin has still to grow — the listener door (it needs
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
## 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.
## Settings that reach the config
`set_settings` takes **camelCase** arguments (Tauri's rule for command
@@ -994,12 +1107,16 @@ of theirs streamed → a buddy's sheet → You (every switch and chip saved
and read back from the config) → recent searches and a spelling
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
record with a long sheet over it, an artist page, and the side insets faked,
all at 915×411) and back, asserts what each step should show — the Home
all at 915×411) and back → and last, on a reload of its own, a phone that
has just been installed (the first-run sheet and its two cards, its
Soulseek door, the notes on Home, on both Library tabs and on Search, the
preview from You, a folder picked from the tour and the note losing the row
it answered), then back to the provisioned device, asserts what each step should show — the Home
step holds the launch to a budget as well, reading off `__ss.boot` how many
commands the app issued before its first frame, how many of those left in the
shell's own turn, how long it took, and requiring round 12's art prewarm to
still be absent from them — screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The block at the end of the mock walk is the
any exception or console error. Near the end of the mock walk is the
weather: a library index that will not read and its Try again, the client taken
offline, a refused password and a server that cannot be reached, Search with
nowhere to search, a room list that never arrives, and a rescan that refuses
@@ -1100,3 +1217,15 @@ symptom.
- **Per library folder.** Follows and the play history live in the library
folder, so the phone's own folder starts empty of both; they arrive with a
shared library.
- **A first run that restores rather than starts.** The tour covers the two
things the app cannot work without (a folder, an account) and stops
there. The desktop's own tour gates on an alpha key, which has nothing to
unlock here — the APK is side-loaded and there is no updater channel on
the phone — and its Community step has no counterpart, because Freenet is
not on the phone. The honest first run for Kyle's own phone is neither:
it is "point me at the desktop's library folder and read its follows, its
playlists and its play history", which works only when that folder is
reachable from the phone's storage. That is a sync door, not a setup
step. Making an account from inside the app is possible — Soulseek makes
one from the first sign-in with a new name — but it would mean handling a
password, which the tour deliberately refuses.