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.
+57 -2
View File
@@ -352,6 +352,23 @@ function mockImport(cmd, args) {
return undefined;
}
/* The walk's other device: a phone that has just installed the app. The
mock is the whole backend on that leg, so it can answer as one — and
the flag has to survive `location.reload()`, which is why it rides in
the URL fragment as well as in localStorage: a file:// document's
storage is not something the harness can lean on, and a fragment
always comes back. */
const fresh = (() => {
try {
if (location.hash.indexOf("fresh") >= 0) return true;
} catch { /* no document */ }
try {
return localStorage.getItem("mockFresh") === "1";
} catch {
return false;
}
})();
const enabled = (() => {
try {
return localStorage.getItem("mockLibrary") === "1";
@@ -367,6 +384,14 @@ export function mockInvoke(cmd, args) {
command arm exactly the same way (see the doors below) */
const forecast = weather(cmd);
if (forecast) return forecast;
/* a device that has just been installed: an empty database. Not an
error and not a refusal — `pipeline::init` scans the sandbox root
at startup and honestly finds nothing in it, so every one of these
answers with an empty list. Everything else below answers exactly
as it always did: this is the same mock with nothing in it yet. */
if (fresh && (cmd === "library_albums" || cmd === "library_album_stats" || cmd === "recent_albums"
|| cmd === "followed_artists" || cmd === "list_playlists" || cmd === "library_track_index"
|| cmd === "library_track_search" || cmd === "contacts_meta_list" || cmd === "buddy_list")) return Promise.resolve([]);
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
@@ -740,7 +765,7 @@ function mockCatalog(cmd, args) {
return Promise.resolve();
}
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: !fresh });
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
/* the downloads notice: the mock walk cannot see the shade, so the calls
@@ -762,6 +787,7 @@ let downloadSeq = 0;
const mockSettings = {
download_dir: "C:\\mock\\Library",
volume_normalization: true,
setup_done: true,
library_layout: "{artist}\\{album}",
shared_folders: [],
upload_slots: 2,
@@ -777,6 +803,18 @@ const mockSettings = {
stream_source_prefs: { max: "any", min: "any", priority: "quality" },
download_source_prefs: { max: "any", min: "any", priority: "quality" },
};
/* the fresh device's own config: the library root Android actually hands
a fresh install (the app's private sandbox — real, writable, and
invisible to every other app), nothing shared, and the flag the tour
stamps still unwritten. Mutated rather than replaced, so `set_settings`
keeps working and a folder picked during the walk genuinely changes
what the notes read. */
if (fresh) Object.assign(mockSettings, {
download_dir: "/data/user/0/com.soulstream.app/files/Library",
setup_done: false,
share_files: 0,
shared_folders: [],
});
const cacheReady = new Set(); // paths a preload or a finished stream has fully fetched
const cacheSizes = new Map(); // path → the file's size, for stream_cache_len
const preloading = new Set();
@@ -786,7 +824,7 @@ let posTimer = null; // the fake engine's position ticks
them — a declaration moves both, the way the real server does */
const MY_INTERESTS = { likes: ["Daft Punk", "french house", "Warp Records"], hates: ["loudness war"] };
function mockNet(cmd, args) {
if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true });
if (cmd === "get_credentials") return Promise.resolve(fresh ? { username: "", has_password: false } : { username: MOCK_USER, has_password: true });
if (cmd === "connect_stored" || cmd === "connect_and_login") {
later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }));
return Promise.resolve();
@@ -908,6 +946,23 @@ function mockNet(cmd, args) {
return new Promise((r) => setTimeout(() => r(pool.slice(0, Math.min(pool.length, n + 1)).map((username, i) => ({ username, rating: Math.max(1, n - i) }))), 300));
}
if (cmd === "player_state") return Promise.resolve({ status: "idle", position: 0 });
/* Two the phone can reach that the mock never knew, found while
mapping every api.js wrapper against it for this round: unmocked
they fall through to the real IPC, which on a file:// page rejects —
a console warning AND a toast, in a step that has nothing to do with
either. The transport is answered the way the real core answers it,
with an event and nothing else; `resume` cannot restart the position
ticks (they live in the closure the play_* command opened), so the
state is right and the position simply stands still. */
if (cmd === "player_control") {
const act = String(args?.action || "");
if (act === "pause" || act === "stop") { clearInterval(posTimer); posTimer = null; }
if (act === "pause") later(60, () => mockEmit("soulseek:player", { kind: "paused" }));
if (act === "stop") later(60, () => mockEmit("soulseek:player", { kind: "stopped" }));
if (act === "resume") later(60, () => mockEmit("soulseek:player", { kind: "playing" }));
return Promise.resolve();
}
if (cmd === "open_external") return Promise.resolve();
if (cmd === "preload_local") return Promise.resolve();
if (cmd === "waveform") {
const n = args?.buckets || 900;
+34
View File
@@ -21,6 +21,7 @@
import { art, wantArt } from "./art.svelte.js";
import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
import { setup, needsFolder, needsAccount, chooseFolder, requestAccess, finishSetup } from "./setup.svelte.js";
import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
@@ -778,6 +779,39 @@
{/each}
</div>
<div style="padding: 6px var(--pad) 16px"><button class="btn btn-ghost wide" onclick={act(() => { leaveRoom(shown.arg.name).catch(() => {}); back(); })}>Leave #{shown.arg.name}</button></div>
{:else if kind === "setup"}
<!-- the first run: one sheet, two cards, and a foot that closes
it. Every row here is a row that exists somewhere else —
the folder picker is You's, the sign-in is Community's —
because the tour's job is to say WHICH two things matter,
not to become a third place they can be changed. -->
<div class="setup">
<div class="sheet-title">{setup.reason === "preview" ? "Set up this phone" : "Welcome to SoulStream"}</div>
<div class="sec-note">Your library, the catalog and the network, in one place. Two things make the rest of it work — neither is required now, and both live on a screen you can come back to.</div>
<div class="sheet-sec">Where your music lives</div>
<div class="sec-note">One folder is the library: everything saved or downloaded lands in it, and everything in it is in the library. Until you pick one it is a folder inside the app that nothing else on this phone can see.</div>
<div class="setup-path">{setup.folder || "—"}</div>
<div class="sheet-rows">
{@render row("folder", setup.picking ? "Picking…" : needsFolder() ? "Choose a folder" : "Choose a different folder", needsFolder() ? "somewhere on this phone's own storage" : "this one is yours", () => void chooseFolder())}
{#if !needsFolder() && setup.access === false}
{@render row("alert", "Allow all files access", "a folder on the phone's storage needs it — the system screen opens, come back after", () => void requestAccess())}
{/if}
</div>
<div class="sheet-sec">Soulseek</div>
<div class="sec-note">Anything the library does not hold comes off the network: what Search finds, a song played from someone's copy, Save. The card asks for a username and a password and keeps them on this phone — nothing here does.</div>
{#if needsAccount()}
<div class="sheet-rows">
{@render row("users", "Sign in on Community", "the card is on the Community tab", act(() => go("community")))}
</div>
{:else}
<div class="sheet-info"><div class="kv"><span class="k">Signed in</span><span class="v">{slsk.username || slsk.savedUser}</span></div></div>
{/if}
<div class="setup-foot"><button class="btn btn-fill wide" onclick={() => void finishSetup()}>Start listening</button></div>
<div class="sec-note" style="padding-bottom:16px">Nothing above is required. You → Set up this phone again opens it back up.</div>
</div>
{:else if kind === "sleep"}
<div class="sheet-title">Sleep timer</div>
<div class="sheet-sec">Pause after</div>
+30 -3
View File
@@ -42,6 +42,7 @@
import { player, hooks, bindEvents, restoreSession, next, prev, toggle, seekSecs, playQueueAt } from "./player.svelte.js";
import { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
import { loadPrefs } from "./prefs.svelte.js";
import { setup, loadSetup, maybeOpenFirstRun, openSetup, finishSetup, setupState } from "./setup.svelte.js";
import { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";
@@ -159,6 +160,24 @@
which is why that record is a plain object. */
$effect(() => { syncShade(); });
/* ── the first run stamps when it goes away, whatever closed it ────
The foot's button calls finishSetup itself; the scrim, the back
gesture and the Soulseek row (which navigates, and navigating clears
the half sheet) do not — and a tour that ran is a tour that ran.
finishSetup writes only in boot mode and only once, so all four
doors land in the same place, and a PREVIEW opened from You writes
nothing at all.
untrack is not optional here: finishSetup calls closeHalf, which
reads nav.half, which is exactly what this effect tracks. Read your
own write in a Svelte 5 effect and the subtree dies painted. */
let setupWasUp = false;
$effect(() => {
const up = nav.half?.kind === "setup";
if (setupWasUp && !up) untrack(() => { void finishSetup(); });
setupWasUp = up;
});
/* ── 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
@@ -248,9 +267,17 @@
.catch(() => {});
hooks.recentsDirty = refreshRecents;
bindMedia();
loadLibrary();
/* the first run. The config is read at boot because the notes on
the screens that are empty BECAUSE of it read this store — and
the tour itself is offered only once the library and the saved
account have both answered. Asked any earlier they are `[]` and
`""` on every phone in existence, Kyle's included, and the sheet
that must never open on his would open on his. */
const configRead = loadSetup();
const libRead = loadLibrary();
/* the network: subscribe first, then the saved account signs in on its own */
bindSlsk().then(() => connectStored()).catch(() => {});
const netRead = bindSlsk().then(() => connectStored()).catch(() => {});
Promise.all([configRead, libRead, netRead]).then(() => maybeOpenFirstRun()).catch(() => {});
bindNet();
watchLibrary();
bindTransfers();
@@ -278,7 +305,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
+39
View File
@@ -0,0 +1,39 @@
<script>
/* What a screen says when it is empty because of something the phone
has not been told yet — with the row that tells it. Only the things
actually missing are drawn, and nothing at all when both are
answered, so a phone that is set up never sees this component. It
does not ask for a password either: the Soulseek row goes to the
card on Community and stops there.
No props: it says the same thing on all four screens, because it is
the same thing. The stub above it is each screen's own words. */
import Icon from "../components/Icon.svelte";
import { go } from "./nav.svelte.js";
import { setup, needsFolder, needsAccount, chooseFolder } from "./setup.svelte.js";
const folder = $derived(needsFolder());
const account = $derived(needsAccount());
</script>
{#if folder || account}
<div class="stub setup-note">
<b>Two things make the rest of this work.</b>
<div class="setup-rows">
{#if folder}
<button class="row" onclick={() => void chooseFolder()}>
<span class="lead"><Icon name="folder" size={20} /></span>
<span class="b"><span class="t">{setup.picking ? "Picking…" : "Choose your library folder"}</span><span class="m">music on this phone lives there; right now it is inside the app</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
{#if account}
<button class="row" onclick={() => go("community")}>
<span class="lead"><Icon name="users" size={20} /></span>
<span class="b"><span class="t">Sign in on Soulseek</span><span class="m">search, streaming and Save all come off the network</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
</div>
</div>
{/if}
+3 -1
View File
@@ -11,6 +11,7 @@
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import * as api from "../../lib/api.js";
import { lib, newest, tracksOf } from "../lib.svelte.js";
import { art, visible } from "../art.svelte.js";
@@ -215,8 +216,9 @@
{:else if lib.loaded}
<div class="stub">
<b>Nothing in the library on this phone yet.</b>
Music the phone can play lives in the app's own Library folder for now; follow artists and Save records from the catalog, and this page fills up.
Save records from the catalog and follow the artists you collect, and this page fills up with what they release.
</div>
<SetupNote />
{/if}
{#if catalogDown}
+6
View File
@@ -4,6 +4,7 @@
survive a push and a pop. */
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import Cover from "../Cover.svelte";
import Tracks from "./Tracks.svelte";
import PlaylistShelf from "../PlaylistShelf.svelte";
@@ -125,6 +126,7 @@
</div>
{#if !list.length}
<div class="stub"><b>Nothing here yet</b>The library on this phone is empty.</div>
<SetupNote />
{:else if view === "grid"}
<div class="grid2">
{#each list as a (a.id)}
@@ -142,6 +144,10 @@
{/if}
{:else if tab === "artists"}
<div class="sec-note">{arts.length} artists · {lib.follows.length} followed</div>
{#if !arts.length}
<div class="stub"><b>No artists yet</b>Every artist with a record in the library on this phone is listed here.</div>
<SetupNote />
{/if}
{#each arts as ar (ar.name)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
+2 -1
View File
@@ -10,6 +10,7 @@
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import Cover from "../Cover.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
@@ -300,8 +301,8 @@
<div class="stub">
<b>The catalog, your library{online ? " and the network" : ""}.</b>
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
{#if !online}Sign in on Community to play what the library does not hold.{/if}
</div>
<SetupNote />
{#if lib.recents.length}
<div class="sec"><h2>Jump back in <em>· picks up where you left off</em></h2></div>
<div class="rail">
+9 -2
View File
@@ -9,6 +9,7 @@
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
import { setup, openSetup, loadSetup, isPrivateFolder } from "../setup.svelte.js";
import { refreshSourcePrefs } from "../../lib/sources.js";
import { devBuild } from "../util.js";
@@ -16,7 +17,7 @@
let scanning = $state(false);
/* the library root the backend could not reach, out of its refusal */
let folderGone = $state("");
$effect(() => { api.getSettings().then((s) => { cfg = s; }, () => {}); });
$effect(() => { void setup.folder; api.getSettings().then((s) => { cfg = s; }, () => {}); });
/* the desktop's Playback setting: every track brought to −14 LUFS (the
player crate's BS.1770 meter; on by default) */
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
@@ -36,7 +37,7 @@
document.addEventListener("visibilitychange", onShow);
return () => document.removeEventListener("visibilitychange", onShow);
});
const privateFolder = $derived(!cfg?.download_dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(cfg.download_dir));
const privateFolder = $derived(isPrivateFolder(cfg?.download_dir));
let picking = $state(false);
async function pickFolder() {
if (picking) return;
@@ -46,6 +47,7 @@
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
await api.setSettings({ downloadDir: r.path });
cfg = await api.getSettings();
void loadSetup();
await rescan();
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
@@ -375,6 +377,11 @@
<div class="group">
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
<div class="kv"><span class="k">Soulseek</span><span class="v dim">{lib.username || "not signed in"}</span></div>
<button class="row" onclick={() => openSetup("preview")}>
<span class="lead"><Icon name="sparkle" size={20} /></span>
<span class="b"><span class="t">Set up this phone again</span><span class="m">the first run — where your music lives, and the network; nothing changes by looking</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if devBuild}
<button class="row" onclick={() => push("diag", {})}>
<span class="lead"><Icon name="devices" size={20} /></span>
+169
View File
@@ -0,0 +1,169 @@
/* The first run.
What a phone that has just installed the app actually holds: a config
file that does not exist yet, an empty database, no Soulseek account —
and a library root that is NOT missing. `config.rs`'s `download_dir`
falls back on Android to `app_data_dir()/Library`, a real, writable
folder inside the app's own sandbox that nothing else on the phone can
see, and it returns before the desktop's audio_dir() fallback is even
reached. So the root is never null and "no folder yet" can never be a
null check anywhere: the test is the PATH (isPrivateFolder), which
You.svelte carried first and now imports from here, so there is one of
it rather than two drifting apart.
Two things the app genuinely cannot work without, and both are already
fixable on a screen that exists: somewhere to keep music (You's folder
picker and the all-files permission beside it) and, for anything that
touches the network, a Soulseek sign-in (Community's card). This store
knows which of the two are still missing, opens the sheet that says so,
and stamps `setup_done` once the tour has run. It never handles a
password: the Soulseek step navigates to the card and stops there. */
import * as api from "../lib/api.js";
import { openHalf, closeHalf } from "./nav.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { lib, loadLibrary } from "./lib.svelte.js";
import { player } from "./player.svelte.js";
/** The sandbox root Android hands a fresh install —
* `/data/user/0/com.soulstream.app/files/Library`, or `/data/data/…` on an
* older device. A real folder, and an invisible one, which is why it reads
* as "no folder picked yet". A string match is all there is: change the
* package id and every phone silently reads as set up. */
export function isPrivateFolder(dir) {
return !dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(dir);
}
export const setup = $state({
/** the config has been READ — not that it said anything in particular */
loaded: false,
/** "boot" = the tour opened itself · "preview" = You opened it ·
* "shown" = it has already stamped and must not stamp again */
reason: "boot",
folder: "",
private: true,
access: null, // all-files access: true | false | null (never asked)
done: false, // the config's setup_done
picking: false,
/** stamps this store has written. A preview must leave it at zero — it
* is the whole proof that the door in You is not a door onto the
* config, and the phone walk asserts it on Kyle's own phone. */
writes: 0,
});
/** Still the app's own sandbox folder. */
export function needsFolder() {
return setup.loaded && setup.private;
}
/** Signed out, with no saved account to sign in with. Gated on the
* credentials having been read: before that every phone looks signed out,
* and a note that flashes on a set-up phone is a lie with good timing. */
export function needsAccount() {
return slsk.credsRead && slsk.connection !== "online" && !slsk.savedUser;
}
/** What the walk reads back: "<folder>:<account>:<flag>". */
export function setupState() {
return (setup.private ? "private" : "chosen")
+ ":" + (needsAccount() ? "out" : "in")
+ ":" + (setup.done ? "done" : "todo");
}
export async function loadSetup() {
let cfg = null;
try { cfg = await api.getSettings(); } catch { return; }
setup.folder = cfg?.download_dir || "";
setup.private = isPrivateFolder(cfg?.download_dir);
setup.done = !!cfg?.setup_done;
setup.loaded = true;
await refreshAccess();
}
/** All-files access: true, false, or null where the phone never asked
* (an old device, or the desktop). A failed read leaves the last answer
* standing rather than turning the row back on. */
async function refreshAccess() {
try { setup.access = (await api.allFilesAccess())?.granted ?? null; } catch { /* keep what we had */ }
}
/** Ask for it. The system answers by taking the app away and giving it
* back, so the grant is re-read on the way back — You.svelte watches
* visibility for exactly this reason, and without it the row stays on
* screen offering a permission that has already been given. */
export async function requestAccess() {
try { await api.requestAllFilesAccess(); } catch (e) { player.error = String(e); return; }
const back = () => {
if (document.hidden) return;
document.removeEventListener("visibilitychange", back);
void refreshAccess();
};
document.addEventListener("visibilitychange", back);
}
/** The tour opened by hand — You's "Set up this phone again". A preview:
* it reads the real state and writes nothing, which is what makes it
* something the phone walk can drive on Kyle's own device. */
export function openSetup(reason = "preview") {
setup.reason = reason;
void loadSetup();
openHalf("setup");
}
/** The boot gate: three clauses, ALL of them required.
*
* The desktop gates on `setup_done` alone (App.svelte), and that alone
* would greet Kyle with a setup sheet on a phone full of his own music —
* the phone has never written the flag, and `config::setup_done` unwraps a
* missing field to false, so every phone in existence reads as unstamped.
* A saved account, or one record in the library, says this is not a fresh
* install whatever the flag says.
*
* Both of those are read from the backend, so the caller has to let
* `loadLibrary()` and `connectStored()` settle first: asked early they are
* `[]` and `""` on every phone there is, and the gate would open on all of
* them. */
export async function maybeOpenFirstRun() {
if (!setup.loaded) await loadSetup();
if (!setup.loaded) return false;
if (setup.done || slsk.savedUser || lib.albums.length) return false;
setup.reason = "boot";
openHalf("setup");
return true;
}
/** The library folder, picked from the tour — You's own three calls: pick,
* save, rescan. Keeping the private folder is a legitimate answer, so
* nothing here is required and a cancelled picker is not an error. */
export async function chooseFolder() {
if (setup.picking) return;
setup.picking = true;
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
await api.setSettings({ downloadDir: r.path });
await loadSetup();
await api.rescanLibrary();
await loadLibrary();
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
} finally {
setup.picking = false;
}
}
/** Close the tour and remember that it ran — whichever door closed it: the
* foot's button, the scrim, the back gesture, or a row that navigated away
* (a tour that ran is a tour that ran). `reason` moves to "shown" BEFORE
* the await, so two doors closing in the same tick still stamp once.
*
* A preview writes nothing at all. The write is best effort otherwise: a
* failed save leaves the flag false and the tour comes back on the next
* launch, which is the honest outcome — nothing else depends on it. */
export async function finishSetup() {
closeHalf();
if (setup.reason !== "boot") return;
setup.reason = "shown";
try {
await api.setSettings({ setupDone: true });
setup.done = true;
setup.writes++;
} catch { /* see above */ }
}
+26
View File
@@ -1563,3 +1563,29 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
(`.m mono`, which .row does not give a face — leave that as it is rather
than making these two rows disagree). */
.row.folder-gone .t { color: #ffb4b4; }
/* ── the first run ───────────────────────────────────────────────────────
A phone that has just installed the app: the tour's sheet, and the note
the screens that are empty BECAUSE of it carry. Both are the app's own
parts — .sheet-sec, .sheet-rows, .row, .stub — so there is almost nothing
here: the folder's path, the foot's button, and the note's rows pulled
out to the card's own edge so they read as part of the message rather
than as a second card floating under it. */
.setup-path {
padding: 0 var(--pad) 10px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11.5px;
color: var(--text-muted);
word-break: break-all;
}
.setup-foot { padding: 10px var(--pad) 4px; }
/* the note: one message, with the rows that answer it under a hairline */
.setup-note .setup-rows {
margin: 12px -16px 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.setup-note .setup-rows .row { padding: 10px 16px; }
.setup-note .setup-rows .row + .row { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); }
.setup-note .setup-rows .row .t { font-size: 13.5px; }
.setup-note .setup-rows .row .m { font-size: 12px; }
+7
View File
@@ -13,6 +13,8 @@ export const slsk = $state({
/** the saved username, for the sign-in card */
savedUser: "",
hasPassword: false,
/** the credentials have been READ — see loadCredentials */
credsRead: false,
/** [{ name, users }] — the server's public list */
rooms: [],
/** room → { users: [{ username, … }], messages: [{ username, message, ts }] } */
@@ -45,6 +47,11 @@ export async function loadCredentials() {
slsk.savedUser = c?.username || "";
slsk.hasPassword = !!c?.has_password;
} catch { /* fine */ }
/* read, whatever it said. "Signed out" is a fact the first run has to
be able to state, and before this resolves every phone in the world
merely LOOKS signed out — a note that says so then is a lie with
good timing. */
slsk.credsRead = true;
}
/** Sign in. The phone always remembers: there is no credential manager to
+120 -3
View File
@@ -251,6 +251,23 @@ const PROBE = `(function () {
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
/* the first run: the sheet, the two cards it offers, and the state
it read out of the config — "<folder>:<account>:<flag>".
setupWrites is the number of times the store has stamped
setup_done, and a PREVIEW must leave it where it found it: that
is the whole proof that the door in You is not a door onto Kyle's
own config, and the phone walk asserts it on his phone. */
setupUp: !!document.querySelector('.halfsheet.up .setup'),
setupCards: [].map.call(document.querySelectorAll('.halfsheet.up .setup .sheet-sec'), function (s) { return s.textContent.trim(); }).join('|'),
setupState: (function () { try { return __ss.setupState(); } catch (e) { return ''; } })(),
setupWrites: (function () { try { return __ss.setup.writes; } catch (e) { return -1; } })(),
/* the note the blocked screen carries, and the rows it offers: a
row goes away because the config changed, not because a flag was
flipped, which is why the count is the thing worth reading */
setupNotes: document.querySelectorAll('.screen .setup-note').length,
setupRows: document.querySelectorAll('.screen .setup-note .row').length,
setupNoteText: [].map.call(document.querySelectorAll('.screen .setup-note .row .t'), function (t) { return t.textContent.trim(); }).join('|'),
signIn: ((document.querySelector('.signin .signin-t') || {}).textContent || '').trim(),
/* the boot census, latched at the shell's first frame and never
re-opened: how many commands the app asked for before it had
drawn anything, how many of those left in the shell's own turn,
@@ -836,6 +853,50 @@ const MOCK_STEPS = [
{ do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 },
{ do: `__settle()`, wait: 900 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 },
/* ── the first run, on a device that has just been installed ──────
The mock is the whole backend here, so it can answer as one: no
records, no playlists, no account, no all-files permission, and the
library root still the sandbox folder Android hands a fresh
install. The flag rides in the URL fragment — a reload cannot lose
that the way a file:// document's storage might — and in
localStorage beside it, for a page opened by hand.
This block is LAST on purpose. The reload wipes every module's
state (the mock's playlists, its contacts, its one fake transfer),
so nothing the two hundred steps above built survives into it, and
it puts the walk back on a provisioned device at the end so that
nothing appended after it is quietly running against an empty one.
A reload is not a reinstall: the webview's own storage comes back
with it, so the session the walk left behind is cleared by hand —
a now-playing bar on a phone with nothing in it is the one piece
of the pretence that would show in the stills. Removing the key is
not enough on its own, and a `pagehide` listener is not either:
the player writes the session on pagehide, on visibilitychange AND
on a ten-second timer while a track plays (player.svelte.js), and a
CDP-driven reload is not obliged to fire pagehide at all — so the
first attempt at this step came back with the bar still up. The
clearing therefore shuts the door instead of chasing the writers:
setItem is taught to refuse that one key for the few hundred
milliseconds this page has left, so nothing can put it back. */
{ do: `(function () { var K = 'ss.mobile.session.v1'; var set = Storage.prototype.setItem; Storage.prototype.setItem = function (k) { if (k === K) return; return set.apply(this, arguments); }; try { localStorage.removeItem(K); } catch (e) {} localStorage.setItem('mockFresh', '1'); location.hash = 'fresh'; })(); true`, wait: 400 },
{ do: `location.reload()`, name: "fresh-boot", wait: 4500 },
/* the tour's Soulseek step navigates to the card and stops there —
nothing in the walk types a username or a password */
{ do: `__click('.halfsheet .row', 'Sign in on Community')`, name: "fresh-community", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-home", wait: 1000 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "fresh-albums", wait: 900 },
/* the Artists tab had no empty state at all before this round */
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "fresh-artists", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "fresh-search", wait: 900 },
/* the preview door: it reads the state and writes nothing */
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
{ do: `__click('.screen .row', 'Set up this phone again')`, name: "fresh-preview", wait: 1100 },
{ do: `__click('.halfsheet .row', 'Choose a folder')`, name: "fresh-folder", wait: 2000 },
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
{ do: `location.reload()`, name: "fresh-off", wait: 4500 },
];
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
@@ -1095,6 +1156,40 @@ const MOCK_EXPECT = {
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live),
"cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
/* ── the first run ────────────────────────────────────────────────
Every one of these is on state that STAYS: a sheet that is up, a
counter, a field in the config, a row that is present or absent.
Nothing here waits on a notice or a picker — both windows are
shorter than the step's own wait plus the screenshot. */
"fresh-boot": (i) => i.setupUp && i.setupCards === "Where your music lives|Soulseek"
&& i.setupState === "private:out:todo" && i.setupWrites === 0
&& /^dialog\/true\/Welcome to SoulStream\/focus$/.test(i.dlg)
/* and nothing of the walk before it came through the reload: a
phone that has just been installed has no session to restore */
&& !i.mini
&& !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
/* the tour's own door to the sign-in, and the stamp that a tour which
ran leaves behind whichever door closed it */
"fresh-community": (i) => !i.setupUp && i.title.startsWith("Community")
&& i.signIn === "Sign in to Soulseek" && i.setupWrites === 1 && i.setupState === "private:out:done",
"fresh-home": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 2
&& i.setupNoteText === "Choose your library folder|Sign in on Soulseek",
"fresh-albums": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2
&& i.setupNoteText === "Choose your library folder|Sign in on Soulseek",
"fresh-artists": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2,
"fresh-search": (i) => i.title.startsWith("Search") && i.setupNotes === 1 && i.setupRows === 2,
/* the preview reads the real config and adds nothing to the counter */
"fresh-preview": (i) => i.setupUp && i.setupWrites === 1 && i.setupCards === "Where your music lives|Soulseek",
/* a folder picked from the tour: the config moved, the permission the
new folder needs is offered, and still nothing was stamped */
"fresh-folder": (i) => i.setupUp && i.setupState === "chosen:out:done" && i.setupWrites === 1
&& /Allow all files access/.test(i.halfText),
/* and the note loses the row it answered — because the config changed,
not because a flag was flipped */
"fresh-fixed": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 1
&& i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1,
/* the provisioned device is back, and it is NOT offered a first run */
"fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast,
};
/* the phone walk: a record by name, played for real */
@@ -1255,6 +1350,15 @@ const PHONE_STEPS = [
nothing is written. It also catches a build where trouble.js simply
did not ship: __ss.say would not be there and the line stays empty. */
{ do: `(function () { var raw = ['not connected to server', 'not connected to Soulseek', 'io: failed to lookup address information: no address associated with hostname', 'protocol: unexpected end of frame', 'login failed: INVALIDPASS', 'could not reach server: io: connection refused', 'timed out', 'user dusty_crates is offline or unknown', 'no stored credentials', 'unable to open database file', 'library-folder-missing: /storage/1A2B-3C4D/Music']; window.__trouble = raw.map(function (r) { return __ss.say(r); }).join(' || '); return window.__trouble; })()`, name: "trouble-words", wait: 400 },
/* the first run's preview door, on Kyle's own phone. It READS his
state — his folder, his account — and must write nothing at all: the
walk never clears his config, never re-runs setup and never stamps
the flag, and `setupWrites` staying at zero is what says so. Driven
through __ss rather than the row in You so the step cannot miss it
on a long screen, and closed by the scrim, which on a preview is the
same no-op as the button. Nothing is typed and nothing is picked. */
{ do: `__ss.openSetup('preview')`, name: "setup-preview", wait: 1300 },
{ do: `document.querySelector('.scrim').click()`, name: "setup-closed", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
];
const PHONE_EXPECT = {
@@ -1348,6 +1452,13 @@ const PHONE_EXPECT = {
"trouble-words": (i) => i.trouble.split(" || ").length === 11
&& i.trouble.split(" || ").every(function (s) { return s.length > 12; })
&& !/io:|protocol:|os error|INVALIDPASS|not connected|library-folder-missing|no stored credentials/i.test(i.trouble),
/* both cards on a phone that is fully set up, and the counter at zero
on the way in and on the way out — the preview wrote nothing to his
config. `setupWrites` would also be 1 if the boot gate had fired on
his phone, which is the other thing this catches. */
"setup-preview": (i) => i.setupUp && i.setupWrites === 0
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
"setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast,
end: (i) => /^(Good |Still)/.test(i.title),
};
@@ -1525,9 +1636,11 @@ async function main() {
await cdp.send("Runtime.enable");
await cdp.send("Log.enable");
};
/* the emulated phone, kept: a re-attach has to be handed it again */
let metrics = { width: 411, height: 914, deviceScaleFactor: 2, mobile: true };
if (!PHONE) {
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true });
await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href });
await sleep(600);
}
@@ -1546,7 +1659,8 @@ async function main() {
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
else {
try {
const r = await cdp.send("Emulation.setDeviceMetricsOverride", { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true });
metrics = { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true };
const r = await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
if (r.error) throw new Error(r.error.message || JSON.stringify(r.error));
} catch (e) { problems.push("VIEW " + (s.name || s.view.join("x")) + ": " + e.message); }
}
@@ -1560,7 +1674,10 @@ async function main() {
}
await sleep(s.wait || 500);
if (s.do === "location.reload()") {
try { await reattach(); } catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
try {
await reattach();
if (!PHONE) await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
} catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
}
if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`));