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.
537 lines
29 KiB
JavaScript
537 lines
29 KiB
JavaScript
// 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}
|
||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">{player.error || lib.error}</div>
|
||
{/if}`,
|
||
` {#if player.error || lib.error}
|
||
{@const msg = player.error || lib.error}
|
||
<!-- the notice's shape, in the red: the sentence trouble.js makes of
|
||
whatever Rust said, and one action beside it where there is one to
|
||
offer — Sign in when nobody is, else the Try again that was armed
|
||
for THIS message and no other -->
|
||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; player.retry = null; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">
|
||
<span class="nt">{say(msg)}</span>
|
||
{#if signedOut(msg)}
|
||
<button class="na" onclick={(e) => { e.stopPropagation(); player.error = ""; lib.error = ""; player.retry = null; go("community"); }}>Sign in</button>
|
||
{:else if player.retry?.for === msg}
|
||
<button class="na" onclick={(e) => { e.stopPropagation(); const r = player.retry; player.error = ""; lib.error = ""; player.retry = null; r.fn(); }}>Try again</button>
|
||
{/if}
|
||
</div>
|
||
{/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: <the exact error>, 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}
|
||
<div class="lyr-note">No lyrics found for this track.</div>
|
||
{/if}`,
|
||
` {:else if lyr.failed}
|
||
<div class="lyr-note">Couldn't look up the words — the lookup didn't answer.</div>
|
||
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Try again, or paste them ⋯</button>
|
||
{:else}
|
||
<div class="lyr-note">No lyrics found for this track.</div>
|
||
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Paste them, or search ⋯</button>
|
||
{/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}
|
||
<div class="stub"><b>No tracks match.</b></div>
|
||
{/if}`,
|
||
`{#if failed}
|
||
<div class="stub why"><b>Couldn't read the library index.</b>{say(failed)}</div>
|
||
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={reread}>Try again</button></div>
|
||
{:else if index && !list.length}
|
||
<div class="stub"><b>No tracks match.</b></div>
|
||
{/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}
|
||
<div class="stub"><b>This playlist is empty.</b>`,
|
||
` {#if loaded && loadErr}
|
||
<div class="stub why"><b>Couldn't read this playlist.</b>{say(loadErr)}</div>
|
||
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={() => { loadErr = ""; again++; }}>Try again</button></div>
|
||
{:else if loaded && !items.length}
|
||
<div class="stub"><b>This playlist is empty.</b>`);
|
||
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(` <div class="stub"><b>The catalog did not answer.</b>{entry.error}</div>
|
||
{/if}`,
|
||
` <div class="stub why"><b>The catalog did not answer.</b>{say(entry.error)}</div>
|
||
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={() => lookupAlbum(id)}>Try again</button></div>
|
||
{/if}`);
|
||
save();
|
||
|
||
// ── the artist page: "had nothing to add" was also said about a failure ──
|
||
load('src/mobile/screens/Artist.svelte');
|
||
rep(` <div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog had nothing to add.</div>`,
|
||
` <div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.</div>`);
|
||
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}<b>{count}</b> 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}<b>{count}</b> 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}
|
||
<div class="sec"><h2>New from your artists`,
|
||
`{#if catalogDown}
|
||
<div class="sec-note cat-down">Couldn't reach the catalog, so there's nothing new from your artists here. What the library holds is below.</div>
|
||
{/if}
|
||
|
||
{#if feedRest.length}
|
||
<div class="sec"><h2>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(` <button class="row" onclick={rescan}>
|
||
<span class="lead"><Icon name="refresh" size={20} /></span>
|
||
<span class="b"><span class="t">{scanning ? "Scanning…" : "Rescan library"}</span><span class="m">looks for new files in the folder, forgets the ones that are gone</span></span>
|
||
</button>`,
|
||
` <button class="row" onclick={rescan}>
|
||
<span class="lead"><Icon name="refresh" size={20} /></span>
|
||
<span class="b"><span class="t">{scanning ? "Scanning…" : "Rescan library"}</span><span class="m">looks for new files in the folder, forgets the ones that are gone</span></span>
|
||
</button>
|
||
{#if folderGone}
|
||
<div class="row folder-gone">
|
||
<span class="lead"><Icon name="alert" size={20} /></span>
|
||
<span class="b"><span class="t">The library folder isn't there</span><span class="m mono">{folderGone}</span></span>
|
||
</div>
|
||
<div class="sec-note" style="padding: 0 14px 12px">Nothing was forgotten — the rescan refused rather than dropping every record it could not see. Reconnect the storage, or pick the folder again above.</div>
|
||
{/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}<div class="signin-err">{slsk.loginError}</div>{/if}`,
|
||
`{#if slsk.loginError}<div class="signin-err">{say(slsk.loginError)}</div>{/if}`);
|
||
rep(` <div class="stub"><b>The server did not answer.</b>{taste.error}</div>`,
|
||
` <div class="stub"><b>The server did not answer.</b>{say(taste.error)}</div>`);
|
||
rep(` {#if !slsk.rooms.length}
|
||
<div class="sec-note">Asking the server for its rooms…</div>`,
|
||
` {#if !slsk.rooms.length}
|
||
{#if slsk.roomsLate}
|
||
<!-- request_room_list is fire-and-forget over the wire, so this
|
||
line used to sit here for ever against a server that
|
||
simply never answered -->
|
||
<div class="sec-note rooms-note">The server hasn't sent its room list.</div>
|
||
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={refreshRooms}>Try again</button></div>
|
||
{:else}
|
||
<div class="sec-note">Asking the server for its rooms…</div>
|
||
{/if}`);
|
||
save();
|
||
|
||
// ── Search: signed out, the network section rendered NOTHING ──────────
|
||
load('src/mobile/screens/Search.svelte');
|
||
rep(` import { latestOf } from "../disco.js";`,
|
||
` import { latestOf } from "../disco.js";
|
||
import { say } from "../trouble.js";`);
|
||
rep(` import { push, openHalf, patch } from "../nav.svelte.js";`,
|
||
` import { push, go, openHalf, patch } from "../nav.svelte.js";`);
|
||
rep(` {#if catErr}<div class="sec-note">{catErr}</div>{/if}`,
|
||
` {#if catErr}<div class="sec-note">{say(catErr)}</div>{/if}`);
|
||
rep(` {#if net.error}<div class="sec-note">{net.error}</div>{/if}`,
|
||
` {#if net.error}<div class="sec-note">{say(net.error)}</div>{/if}`);
|
||
rep(` {/each}
|
||
{/if}
|
||
{/if}
|
||
{/if}
|
||
|
||
<div class="endpad"></div>`,
|
||
` {/each}
|
||
{/if}
|
||
{:else}
|
||
<!-- signed out, the whole network section rendered nothing at all: no
|
||
row where "Search the network" lives, and no reason for its
|
||
absence. The catalog and the library above still answer. -->
|
||
<div class="sec-note offline-note">Not signed in, so nothing here can play from the network yet — sign in on Community.</div>
|
||
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => go("community")}>Go to Community</button></div>
|
||
{/if}
|
||
{/if}
|
||
|
||
<div class="endpad"></div>`);
|
||
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::<LibraryState>();`,
|
||
`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::<LibraryState>();`);
|
||
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');
|