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.
170 lines
7.4 KiB
Plaintext
170 lines
7.4 KiB
Plaintext
/* 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 */ }
|
|
}
|