// Round thirty-one: what the phone says when things go wrong. Thirty rounds
// built the happy path; this one is the other. 59 sites in src/mobile/ put a
// raw Rust string into a field a person reads (53 of them into player.error,
// which the red toast drew verbatim — "could not reach server: io: failed to
// lookup address information" under the password box, in a lift). One
// translator, src/mobile/trouble.js, routed through the places that already
// DRAW an error, fixes all of them at once and keeps the raw string in the
// stores. Five screens that drew a failed read as an empty answer are told to
// stop, two spinners with no end are given one, and rescan_library gets a
// guard: it ran prune_missing() FIRST, so one tap with the library folder
// unreachable emptied the whole index and returned Ok(()). The mock grows a
// weather system (fail / answer / offline / clear) so the walk can drive every
// one of these for the first time.
const fs = require('fs');
const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/';
const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/';
const ins = (name) => fs.readFileSync(scratch + name, 'utf8').replace(/\s+$/, '');
let p, s;
const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); };
const save = () => fs.writeFileSync(root + p, s);
const once = (a) => {
const i = s.indexOf(a);
if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90));
if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90));
return i;
};
const rep = (a, b) => { once(a); s = s.replace(a, () => b); };
const NL = String.fromCharCode(10);
/* The shell.css block is an APPEND with no anchor, and several reps below
re-emit their own anchors, so a second run would double-write where `once`
cannot see it and only throw further down, leaving half a tree. The file
this round CREATES is the one thing that cannot be half-there. */
if (fs.existsSync(root + 'src/mobile/trouble.js')) {
throw new Error('round 31 is already applied in ' + root + ' — reset the tree rather than re-running');
}
// ── the translator ────────────────────────────────────────────────────
fs.writeFileSync(root + 'src/mobile/trouble.js', ins('ins-r31-trouble.txt') + NL);
// ── the look: the error toast gets the notice's shape ─────────────────
load('src/mobile/shell.css');
s = s.replace(/\s*$/, NL) + NL + ins('ins-r31-css.txt') + NL;
save();
// ── the toast, once, for all 59 sinks ─────────────────────────────────
load('src/mobile/Mobile.svelte');
rep(`import { notice, clearNote } from "./notice.svelte.js";`,
`import { notice, clearNote } from "./notice.svelte.js";
import { say, signedOut } from "./trouble.js";`);
rep(`import { slsk, bindSlsk, connectStored, unreadTotal } from "./slsk.svelte.js";`,
`import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";`);
rep(` const announce = $derived([player.error || lib.error, notice.text, notice.action?.label].filter(Boolean).join(". "));`,
` const announce = $derived([say(player.error || lib.error), notice.text, notice.action?.label].filter(Boolean).join(". "));`);
/* NOT the boot block: round 34 rewrites those five lines wholesale (the
first-run tour reads the config there), and an effect is the better home
anyway — lib.error is set again by every later reload, You → Rescan
library included. */
rep(` /* ── the card gets the cover once the thumbnail has landed ───────── */`,
` /* ── a library read that failed gets a Try again ──────────────────
The library is the one thing every screen stands on, and a rejected
read left the app empty with a Rust string over it. The retry is wired
HERE, where the toast is drawn, rather than in lib.svelte.js: that
store reaching for the player store would close an import loop —
player.svelte.js already reads lib. */
$effect(() => {
const e = lib.error;
if (!e) return;
untrack(() => {
player.retry = { for: e, fn: () => { player.error = ""; lib.error = ""; player.retry = null; void loadLibrary(); } };
});
});
/* ── the card gets the cover once the thumbnail has landed ───────── */`);
rep(` {#if player.error || lib.error}
{/if}`);
rep(`patch, contacts, loadContacts };`, `patch, contacts, loadContacts, say, refreshRooms };`);
save();
// ── the store keeps the raw string; the retry knows which one it is for ──
load('src/mobile/player.svelte.js');
rep(` error: "",
});`,
` error: "",
/* the toast's one action, tied to the message it belongs to:
{ for: , fn } — so a later, different error can never
inherit the last one's Try again */
retry: null,
});`);
save();
// ── the room list is fire-and-forget over the wire: give it an end ────
load('src/mobile/slsk.svelte.js');
rep(` /** the room or the person whose screen is open, so its unread stays 0 */
openChat: null,
});`,
` /** the room or the person whose screen is open, so its unread stays 0 */
openChat: null,
/** The server's room list is FIRE-AND-FORGET over the wire: the client
* sends \`room_list\` and there is no reply channel and no timeout
* (crates/soulseek-client/src/lib.rs), so a server that never answers
* left "Asking the server for its rooms…" up for ever. The wait is ours.
* \`roomsWaitMs\` is a plain field so the walk can shorten it rather than
* the harness needing a back door — which does mean the mechanism is
* proved at 1.2 s and the shipped 12 s is a judgement, not a measurement. */
roomsWaitMs: 12000,
roomsLate: false,
});`);
rep(`export function refreshRooms() {
api.slskRoomList().catch(() => {});
}`,
`let roomsTimer = null;
export function refreshRooms() {
slsk.roomsLate = false;
clearTimeout(roomsTimer);
roomsTimer = setTimeout(() => { if (!slsk.rooms.length) slsk.roomsLate = true; }, slsk.roomsWaitMs);
/* a refused request is a late list at once — there is nothing to wait for */
api.slskRoomList().catch(() => { clearTimeout(roomsTimer); slsk.roomsLate = true; });
}`);
/* the handler's BODY, not its `await on("soulseek:room-list",` line: round 32
drops the await from every one of those subscriptions, and this line is the
same either way round */
rep(` slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0));`,
` clearTimeout(roomsTimer);
slsk.roomsLate = false;
slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0));`);
save();
// ── a lyrics lookup that FAILED is not a track with no words ──────────
load('src/mobile/lyrics.svelte.js');
rep(` reduced: null, // { from, to } once one has been made for this track
});`,
` reduced: null, // { from, to } once one has been made for this track
/* the lookup itself failed, as opposed to finding nothing: the face has
said the wrong one of those since it was written */
failed: "",
});`);
rep(`candidates: null, searching: false });`, `candidates: null, searching: false, failed: "" });`);
rep(` } catch {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" });
}`,
` } catch (e) {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "", failed: String(e) });
}`);
save();
// ── a failed transfer says why in English ─────────────────────────────
load('src/mobile/transfers.svelte.js');
rep(`import { listen } from "../lib/events.js";
export const transfers = $state({`,
`import { listen } from "../lib/events.js";
import { say } from "./trouble.js";
export const transfers = $state({`);
rep(' case "failed": return d.error ? `failed · ${d.error}` : "failed";',
' case "failed": return d.error ? `failed · ${say(d.error)}` : "failed";');
save();
// ── the resolver's failures carry a Try again ─────────────────────────
load('src/mobile/resolve.svelte.js');
rep(` player.status = "buffering";
player.phase = "loading";
player.error = "";
}`,
` player.status = "buffering";
player.phase = "loading";
player.error = "";
player.retry = null;
}`);
rep(`function fail(msg) {
resolver.error = msg;
player.error = msg;`,
`function fail(msg, retry = null) {
resolver.error = msg;
player.error = msg;
/* a Try again on the toast, tied to THIS message: the ladder is worth
another run when the network simply had nothing this time. Signed out it
is not, and the toast offers Community there instead. */
player.retry = retry ? { for: msg, fn: () => { void retry().catch((e) => { player.error = String(e); }); } } : null;`);
rep(' if (!cands.length) { fail(`No streamable sources found for “${title}”`); return; }',
' if (!cands.length) { fail(`No streamable sources found for “${title}”`, () => streamSong(artist, title, o)); return; }');
rep(' if (!folders.length) { fail(`No streamable sources found for “${album.title}”`); return; }',
' if (!folders.length) { fail(`No streamable sources found for “${album.title}”`, () => streamCatalogAlbum(album, at)); return; }');
save();
// ── the lyrics face: a failure says so, and BOTH quiet branches get the ⋯
// (Paste lyrics was unreachable exactly when you wanted it) ─────────
load('src/mobile/Sheet.svelte');
rep(` {:else}
No lyrics found for this track.
{/if}`,
` {:else if lyr.failed}
Couldn't look up the words — the lookup didn't answer.
{:else}
No lyrics found for this track.
{/if}`);
save();
// ── Tracks: a failed read was drawn as an empty library ───────────────
load('src/mobile/screens/Tracks.svelte');
/* every import below anchors on the line ABOVE the util.js one, never on it:
round 33 rewrites `} from "../util.js";` in four of these files. */
rep(` import { openHalf, patch } from "../nav.svelte.js";`,
` import { openHalf, patch } from "../nav.svelte.js";
import { say } from "../trouble.js";`);
rep(` api.libraryTrackIndex().then(
(rows) => { index = rows || []; },
(e) => { index = []; failed = String(e); },
);
});`,
` api.libraryTrackIndex().then(
(rows) => { index = rows || []; failed = ""; },
(e) => { index = []; failed = String(e); },
);
});
/* a rejected read used to draw "0 tracks · 0:00 · unable to open database
file" and "No tracks match." under it — a failure dressed as an empty
library. Putting the index back to null re-runs the effect above. */
function reread() { failed = ""; index = null; }`);
rep(` {#if index === null}Reading the index…{:else}{index.length} tracks · {longDur(total)}{/if}
{#if list.length > CAP} · showing {CAP} of {list.length}{/if}
{#if failed} · {failed}{/if}`,
` {#if index === null}Reading the index…{:else if failed}The index could not be read.{:else}{index.length} tracks · {longDur(total)}{/if}
{#if !failed && list.length > CAP} · showing {CAP} of {list.length}{/if}`);
rep(`{#if index && !list.length}
No tracks match.
{/if}`,
`{#if failed}
Couldn't read the library index.{say(failed)}
{:else if index && !list.length}
No tracks match.
{/if}`);
save();
// ── a playlist that could not be READ is not an empty playlist ────────
load('src/mobile/screens/Playlist.svelte');
rep(` import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js";`,
` import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js";
import { say } from "../trouble.js";`);
rep(` let items = $state([]);
let loaded = $state(false);
let gen = 0;
$effect(() => {
const p = pl;
if (!p) return;
void p.revision;
const g = ++gen;
api.playlistItems(p.id).then(
(r) => { if (g === gen) { items = r || []; loaded = true; } },
(e) => { if (g === gen) { loaded = true; player.error = String(e); } },
);
});`,
` let items = $state([]);
let loaded = $state(false);
/* a rejected read used to leave items empty and loaded true, so the page
said "This playlist is empty." about a playlist it had not read */
let loadErr = $state("");
let again = $state(0);
let gen = 0;
$effect(() => {
const p = pl;
if (!p) return;
void p.revision;
void again;
const g = ++gen;
api.playlistItems(p.id).then(
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; } },
(e) => { if (g === gen) { loaded = true; loadErr = String(e); } },
);
});`);
rep(` {#if loaded && !items.length}
This playlist is empty.`,
` {#if loaded && loadErr}
Couldn't read this playlist.{say(loadErr)}
{:else if loaded && !items.length}
This playlist is empty.`);
save();
// ── the catalog record: the reason in English, and a way to ask again ──
load('src/mobile/screens/CatalogAlbum.svelte');
rep(` import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js";`,
` import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js";
import { say } from "../trouble.js";`);
rep(`
The catalog did not answer.{entry.error}
{/if}`,
`
The catalog did not answer.{say(entry.error)}
{/if}`);
save();
// ── the artist page: "had nothing to add" was also said about a failure ──
load('src/mobile/screens/Artist.svelte');
rep(`
Nothing here yet.No records by {shownName} on this phone, and the catalog had nothing to add.
`,
`
Nothing here yet.No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.
`);
save();
// ── Gaps: the empty catch that could be tapped for ever ───────────────
load('src/mobile/screens/Library.svelte');
rep(` let gapLoading = $state({});`,
` let gapLoading = $state({});
/* the lookup's catch was empty, so a failed reach put the line back to
"See what's missing" and it could be tapped for ever with no reason */
let gapErr = $state({});`);
rep(` gapLoading = { ...gapLoading, [key]: true };
try {`,
` gapLoading = { ...gapLoading, [key]: true };
gapErr = { ...gapErr, [key]: "" };
try {`);
rep(` } catch { /* the line stays collapsed; nothing is invented */ }`,
` } catch (e) { gapErr = { ...gapErr, [key]: String(e) }; }`);
rep(` {#if gapLoading[key]}Checking the catalogue…{:else if count != null}{count} more released{:else}See what's missing{/if}`,
` {#if gapLoading[key]}Checking the catalogue…{:else if gapErr[key]}Couldn't reach the catalogue — tap to try again{:else if count != null}{count} more released{:else}See what's missing{/if}`);
save();
// ── Home: slot.error has been set since it was written and never read ──
load('src/mobile/screens/Home.svelte');
rep(` const slots = $derived(home.order.map((id) => home.slots[id]).filter(Boolean));`,
` const slots = $derived(home.order.map((id) => home.slots[id]).filter(Boolean));
/* every follow's slot came back empty-handed. home.svelte.js has always
set slot.error on a failed catalog page and nothing has ever read it, so
offline with follows Home drew empty rails and said nothing at all. */
const catalogDown = $derived(lib.follows.length > 0 && slots.length > 0 && slots.every((s) => s.error));`);
/* the rail below the lead, not the empty-library stub above it: round 34
rewrites that stub's copy and puts its own note inside it */
rep(`{#if feedRest.length}
New from your artists`,
`{#if catalogDown}
Couldn't reach the catalog, so there's nothing new from your artists here. What the library holds is below.
{/if}
{#if feedRest.length}
New from your artists`);
save();
// ── You: the rescan can refuse now, and it says which folder ──────────
load('src/mobile/screens/You.svelte');
rep(` let scanning = $state(false);`,
` let scanning = $state(false);
/* the library root the backend could not reach, out of its refusal */
let folderGone = $state("");`);
rep(` async function rescan() {
if (scanning) return;
scanning = true;
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
scanning = false;
}`,
` async function rescan() {
if (scanning) return;
scanning = true;
folderGone = "";
try { await api.rescanLibrary(); await loadLibrary(); }
catch (e) {
/* the backend refuses a rescan whose root it cannot reach
(src-tauri/src/pipeline.rs): the prune inside it would otherwise
delete every row whose file it could not see, which is the whole
library when the card is out */
const msg = String(e);
const TAG = "library-folder-missing:";
const at = msg.indexOf(TAG);
if (at >= 0) folderGone = msg.slice(at + TAG.length).trim();
player.error = msg;
}
scanning = false;
}`);
rep(` `,
`
{#if folderGone}
The library folder isn't there{folderGone}
Nothing was forgotten — the rescan refused rather than dropping every record it could not see. Reconnect the storage, or pick the folder again above.
{/if}`);
save();
// ── Community: the most visible raw-Rust site on the phone ────────────
load('src/mobile/screens/Community.svelte');
rep(` import { taste, loadTaste, setInterest, askSimilar } from "../taste.svelte.js";`,
` import { taste, loadTaste, setInterest, askSimilar } from "../taste.svelte.js";
import { say } from "../trouble.js";`);
rep(`{#if slsk.loginError}
Not signed in, so nothing here can play from the network yet — sign in on Community.
{/if}
{/if}
`);
save();
// ── the harness: the weather doors ────────────────────────────────────
load('src/lib/mockLibrary.js');
rep(`export function mockInvoke(cmd, args) {
if (!enabled) return undefined;`,
`export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
/* the weather, BEFORE any dispatch, so a plugin: command and a catalog
command arm exactly the same way (see the doors below) */
const forecast = weather(cmd);
if (forecast) return forecast;`);
rep(`if (enabled && typeof window !== "undefined") window.__ssMock = { emit: mockEmit };`, ins('ins-r31-mock.txt'));
save();
// ── the walks ─────────────────────────────────────────────────────────
load('tests/mobile/smoke.mjs');
rep(` contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),`,
` contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
` + ins('ins-r31-probe.txt'));
/* the array's end, which is where round 34 appends too — both re-emit it, so
either order works and whichever lands second simply runs last */
rep(`];
const MOCK_EXPECT = {`, ins('ins-r31-steps.txt') + NL + `];
const MOCK_EXPECT = {`);
rep(` "sleep-set": (i) => !i.half.includes("up"),`,
` "sleep-set": (i) => !i.half.includes("up"),
` + ins('ins-r31-expect.txt'));
rep(` { do: \`document.querySelectorAll('.nav button')[0].click()\`, name: "end", wait: 800 },`,
ins('ins-r31-phone-steps.txt') + NL + ` { do: \`document.querySelectorAll('.nav button')[0].click()\`, name: "end", wait: 800 },`);
rep(` end: (i) => /^(Good |Still)/.test(i.title),`,
ins('ins-r31-phone-expect.txt') + NL + ` end: (i) => /^(Good |Still)/.test(i.title),`);
save();
// ── the one-tap library wipe ──────────────────────────────────────────
load('src-tauri/src/pipeline.rs');
rep(`pub async fn rescan_library(app: tauri::AppHandle) -> Result<(), String> {
let state = app.state::();`,
`pub async fn rescan_library(app: tauri::AppHandle) -> Result<(), String> {
// The prune below deletes every track row whose file does not exist, and
// then every album left with none — so a root that has gone away (an
// unmounted card, a revoked SAF grant, a drive letter that moved) used to
// empty the WHOLE index on one tap and return Ok(()). Make the folder if
// it is merely not there yet, so a fresh install still works, and refuse
// if it cannot be made or read. Same idiom as connect_and_login's.
let root = downloads_dir(&app);
if std::fs::create_dir_all(&root).is_err() || !root.is_dir() {
return Err(format!("library-folder-missing: {}", root.display()));
}
let state = app.state::();`);
rep(` let root = downloads_dir(&app);
sweep_stale_partials(&root);`,
` sweep_stale_partials(&root);`);
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
rep(`## The downloads in the shade`, ins('ins-r31-doc.txt') + NL + NL + `## The downloads in the shade`);
/* its own sentence, not a clause inside the walk's chain: round 34 rewrites
that chain's tail ("all at 915×411) and back, asserts …") wholesale */
rep(`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`);
rep(`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`);
save();
console.log('round 31 wired');