android: what the phone says when things go wrong

Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
This commit is contained in:
KeelrLeek committed 2026-09-14 01:23:26 -05:00
1 parent 44eda8dff5
commit 052e15302b
22 files changed
+537 -35

No files matched your search

+99 -2
View File
@@ -623,6 +623,88 @@ it means splitting `.trk` (one flex row with a whole-row `:active`) and
rewriting the walk's `.trk .r` selectors, for no gain a screen reader
would notice.
## When things go wrong
Thirty rounds built the happy path. A phone is also in a lift, signed out, on
a dead cell, or holding a library folder that has gone away, and until this
round every one of those showed up as an empty box, a spinner with no end, or
a Rust string in a red toast.
**One translator**, `src/mobile/trouble.js`. `say(e)` takes what the backend
actually hands up — `ClientError`'s Display impls (`io: {0}`, `protocol: {0}`,
`login failed: INVALIDPASS`, `user {0} is offline or unknown`, `transfer
rejected: {0}`, `timed out`), the commands' own literals (`not connected to
Soulseek`, `no stored credentials`, `no cache dir`, `could not reach server:
{e}`), and rusqlite or `std::io` through `e.to_string()` — and answers with a
sentence a person can act on. The rule that matters: **the stores keep the raw
string and only the DRAWING is translated.** `player.error` still holds what
Rust said, Diagnostics and the walk still read it, and `say()` is called at the
one place the toast is written — which is why fifty-nine `String(e)` sinks were
fixed without touching fifty-nine call sites, and why the sixtieth is covered
too. A message the backend already wrote in English ("The playlist needs a
name") matches no rule and passes through untouched.
The worst one it retires was on the sign-in card: a phone with no DNS drew, in
red under the password box, `could not reach server: io: failed to lookup
address information: no address associated with hostname`. It now says
"Couldn't reach the Soulseek server. Check the connection and try again."
The red toast gained the notice's shape — the sentence, and one action beside
it. **Try again** when retrying is the answer (a resolve that found no sources,
a library read that failed at launch), **Sign in** when `signedOut()` says the
trouble is that nobody is. The action is tied to the exact message it belongs
to (`player.retry = { for, fn }`), so a later, different error can never
inherit the last one's button.
What each screen says now, where it used to say nothing or worse:
- **Tracks** — a rejected `library_track_index` drew "0 tracks · 0:00 · unable
to open database file" and then "No tracks match.", which is a failed read
dressed up as an empty library. Now: "Couldn't read the library index", the
translated reason, and a Try again. "No tracks match" only when the read
SUCCEEDED.
- **A playlist** — a rejected `playlist_items` said "This playlist is empty."
Empty now means empty; failed says so, with a Try again.
- **A catalog record** and **an artist page** — the catalog's own error is
translated, the record's stub offers a Try again, and the artist's stub says
"the catalog didn't answer" rather than "had nothing to add" when that is
what happened.
- **Gaps** (Library) — the lookup's `catch` was empty, so the line flipped back
to "See what's missing" and could be tapped for ever. It now says "Couldn't
reach the catalogue — tap to try again".
- **Home** — `home.svelte.js` sets `slot.error` and nothing read it: offline
with follows, Home drew empty rails and said nothing. One line under the lead
now does, and points at the library below.
- **Search** — the whole network section sat inside `{#if online}` with no
`{:else}`, so signed out with a query typed there was no row and no reason,
just a gap. Now there is a line and a door to Community.
- **The lyrics face** — a lookup that FAILED was drawn as "No lyrics found for
this track.", and that branch was also the only one of the four with no `⋯`
button, so Paste lyrics was unreachable exactly when you wanted it. Both are
fixed: a failure says so, and both branches carry the `⋯`.
- **Transfers** — `failed · <rust>` on a row is `failed · <sentence>`.
**Two spinners were given an end.** `request_room_list` is fire-and-forget over
the wire (crates/soulseek-client/src/lib.rs — the client sends `room_list` and
there is no reply channel and no timeout), so "Asking the server for its
rooms…" could sit there for ever. `slsk.roomsWaitMs` (12 s) arms a timer in
`refreshRooms()`, the `soulseek:room-list` handler clears it, and a server that
never answers gets "The server hasn't sent its room list." and a Try again.
Tracks' own "Reading the index…" has its failure branch above. Everything else
was measured and left alone because it is already bounded: `user_info` 30 s,
`user_interests` 20 s, a browse 45 s, a login 15 s, a net search 15 s.
**One tap could wipe the library.** `rescan_library` (src-tauri/src/pipeline.rs)
ran `prune_missing()` FIRST, and `prune_missing` deletes every track row whose
path does not `exists()`, then every album left with no tracks. With the root
unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of
trouble — You → **Rescan library** emptied the whole index, silently, and
returned `Ok(())`. There is now a guard above the prune: make the root if it is
merely not there yet (a fresh install still works), and refuse with
`library-folder-missing: <path>` if it cannot be made or read. You catches that
into a row under Library folder with the path and what to do about it, and the
toast says it in English.
## The downloads in the shade
Transfers is a screen inside the app; the desktop says what a Save is doing
@@ -836,13 +918,28 @@ 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, screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The mock answers every command the phone
any exception or console error. The block at 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
because the folder is gone — then all of it cleared and the app left as it was
found. The mock answers every command the phone
uses; one it does not know is named in the report (`[mock] no mock for X`)
rather than surfacing as a TypeError toast, console warnings carry their top
frames (the smoke bundle is built unminified for that), and every probe
carries the history mirror — the entry's depth against the layers the app
says the back gesture can peel (`hd`/`hwant`; a drift is how the blank
page after a confirmed delete was found). A mock step can also set the
page after a confirmed delete was found). The mock is also the weather:
`__ssMock.fail(cmd, message)` arms a command to reject with that exact string,
`answer(cmd, value)` arms one to resolve with a value the walk names (an empty
list, a null page), `offline(on)` takes the client down — everything the
backend funnels through `with_client()` answers "not connected to Soulseek",
the two connects answer the server string, and `soulseek:state` goes out — and
`clear()` puts all of it back. They reject with a plain string because that is
what Tauri rejects with. Nine mock steps drive them, and the phone walk carries
one safe step of its own (`trouble-words`: eleven real backend strings through
`__ss.say` on the device, every output asserted free of Rust). A mock step can
also set the
viewport — `{ view: [915, 411] }` sends `Emulation.setDeviceMetricsOverride`
through the same client, which is how the walk turns the phone over and back;
the phone walk refuses one, because a real device's orientation is not the