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.
239 lines
15 KiB
JavaScript
239 lines
15 KiB
JavaScript
// Round twenty-nine: the downloads, in the shade. A Save still running with
|
|
// the app in the background now posts a progress notice — how many files,
|
|
// the one it is on, Stop all and Open — and takes it down when the queue
|
|
// empties. It gets its OWN plugin crate with its OWN Kotlin foreground
|
|
// service (`dataSync`) and its OWN low-importance channel rather than a
|
|
// second card from MediaService: a mediaPlayback service stops itself when
|
|
// the bar empties and a download is not playback, and that service is also
|
|
// what keeps the process alive for a Save with nothing playing — which is
|
|
// exactly how the first real Save died, four tracks in.
|
|
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);
|
|
|
|
/* Two of the edits re-emit their own anchor — Transfers.svelte's Clear
|
|
finished button is re-emitted inside a new `.acts` wrapper, and
|
|
capabilities/default.json re-emits "share:default" with a comma — so a
|
|
second run would nest both where `once` cannot see it, and the steps and
|
|
expectations would double-insert. Refuse one before anything is written. */
|
|
if (fs.existsSync(root + 'src/mobile/xfernote.svelte.js')) {
|
|
throw new Error('round 29 is already applied in ' + root + ' — reset the tree rather than re-running');
|
|
}
|
|
|
|
// ── the crate: its own plugin, its own Kotlin, its own channel ──────────
|
|
const CRATE = root + 'crates/tauri-plugin-transfer-notice/';
|
|
const KT = CRATE + 'android/src/main/java/com/soulstream/transfernotice/';
|
|
for (const d of [CRATE + 'src', CRATE + 'permissions', KT, CRATE + 'android/src/main/res/drawable']) {
|
|
fs.mkdirSync(d, { recursive: true });
|
|
}
|
|
fs.writeFileSync(CRATE + 'Cargo.toml', ins('ins-r29-crate-cargo.txt') + NL);
|
|
fs.writeFileSync(CRATE + 'build.rs', ins('ins-r29-crate-buildrs.txt') + NL);
|
|
fs.writeFileSync(CRATE + 'src/lib.rs', ins('ins-r29-crate-lib.txt') + NL);
|
|
/* permissions/autogenerated/** and permissions/schemas/schema.json are
|
|
written by tauri_plugin::Builder on the first cargo build; the other three
|
|
plugins have them tracked, so `git add` them once they exist. */
|
|
fs.writeFileSync(CRATE + 'permissions/default.toml', ins('ins-r29-perm-default.txt') + NL);
|
|
fs.writeFileSync(CRATE + 'android/.gitignore', '/build' + NL + '/.tauri' + NL);
|
|
fs.writeFileSync(CRATE + 'android/build.gradle.kts', ins('ins-r29-gradle.txt') + NL);
|
|
fs.writeFileSync(CRATE + 'android/src/main/AndroidManifest.xml', ins('ins-r29-manifest.txt') + NL);
|
|
fs.writeFileSync(CRATE + 'android/src/main/res/drawable/ic_soulstream_down.xml', ins('ins-r29-drawable.txt') + NL);
|
|
fs.writeFileSync(KT + 'TransferNoticePlugin.kt', ins('ins-r29-plugin-kt.txt') + NL);
|
|
fs.writeFileSync(KT + 'TransferService.kt', ins('ins-r29-service-kt.txt') + NL);
|
|
|
|
// ── the workspace ──────────────────────────────────────────────────────
|
|
/* The snapshot the rounds are written against holds src/, tests/, crates/,
|
|
src-tauri/ and ANDROID.md — no workspace root — so a dry run against it
|
|
says the member line out loud instead of throwing. Against the repo it is
|
|
an ordinary edit. */
|
|
if (fs.existsSync(root + 'Cargo.toml')) {
|
|
load('Cargo.toml');
|
|
rep(` "crates/tauri-plugin-share",`,
|
|
` "crates/tauri-plugin-share",` + NL + ` "crates/tauri-plugin-transfer-notice",`);
|
|
save();
|
|
} else {
|
|
console.log('note: no workspace Cargo.toml under ' + root + ' — add "crates/tauri-plugin-transfer-notice" to [workspace].members by hand');
|
|
}
|
|
|
|
load('src-tauri/Cargo.toml');
|
|
rep(`tauri-plugin-share = { path = "../crates/tauri-plugin-share" }`,
|
|
`tauri-plugin-share = { path = "../crates/tauri-plugin-share" }
|
|
# The phone's downloads notice (a second Kotlin foreground service, dataSync).
|
|
# Registered everywhere so the permission set resolves on every platform; on
|
|
# the desktop its commands answer "not here".
|
|
tauri-plugin-transfer-notice = { path = "../crates/tauri-plugin-transfer-notice" }`);
|
|
save();
|
|
|
|
load('src-tauri/src/lib.rs');
|
|
rep(` app.handle().plugin(tauri_plugin_share::init())?;`,
|
|
` app.handle().plugin(tauri_plugin_share::init())?;
|
|
// The downloads in the phone's shade: a progress notice on its own
|
|
// channel and its own dataSync service, so a Save survives the app
|
|
// going to the background with nothing playing.
|
|
app.handle().plugin(tauri_plugin_transfer_notice::init())?;`);
|
|
save();
|
|
|
|
load('src-tauri/capabilities/default.json');
|
|
rep(` "share:default"`, ` "share:default",` + NL + ` "transfer-notice:default"`);
|
|
save();
|
|
|
|
load('src-tauri/build.rs');
|
|
rep(`fn main() {` + NL + ` tauri_build::build()` + NL + `}`,
|
|
`// tauri-build only rewrites gen/android/tauri.settings.gradle and
|
|
// app/tauri.build.gradle.kts — the lines that include every plugin's own
|
|
// Android module — when this build script re-runs, so touch this file
|
|
// whenever a plugin is added (ANDROID.md). The downloads notice was the
|
|
// last one.
|
|
fn main() {
|
|
tauri_build::build()
|
|
}`);
|
|
save();
|
|
|
|
// ── the phone: the two commands, and the store's own Stop all ──────────
|
|
load('src/lib/api.js');
|
|
rep(`export const pendingShare = () => invoke("plugin:share|pending_share");`,
|
|
`export const pendingShare = () => invoke("plugin:share|pending_share");
|
|
// The phone's downloads notice (crates/tauri-plugin-transfer-notice): what
|
|
// Save is still fetching, mirrored into the shade; \`clear\` takes it down.
|
|
export const transferNotice = (state) => invoke("plugin:transfer-notice|update", { state });
|
|
export const clearTransferNotice = () => invoke("plugin:transfer-notice|clear");`);
|
|
save();
|
|
|
|
load('src/mobile/transfers.svelte.js');
|
|
rep(`export async function cancel(id) {
|
|
try { await api.cancelDownload(id); } catch { /* already gone */ }
|
|
}`,
|
|
`export async function cancel(id) {
|
|
try { await api.cancelDownload(id); } catch { /* already gone */ }
|
|
}
|
|
|
|
/** Stop everything still in flight — what the shade's Stop all and the
|
|
* screen's own button both call, through the one path a row's tap takes. */
|
|
export async function cancelAll() {
|
|
for (const d of active()) await cancel(d.id);
|
|
}`);
|
|
save();
|
|
|
|
// ── the mirror ─────────────────────────────────────────────────────────
|
|
fs.writeFileSync(root + 'src/mobile/xfernote.svelte.js', ins('ins-r29-xfernote.txt') + NL);
|
|
|
|
load('src/mobile/Mobile.svelte');
|
|
rep(` import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";`,
|
|
` import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
|
|
import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";`);
|
|
rep(` /* ── the card gets the cover once the thumbnail has landed ───────── */`,
|
|
` /* ── the shade's download notice follows the transfer store ───────
|
|
An effect, not a hook off the download event: the effect tracks the
|
|
store itself, progress ticks and all, so a row written straight into
|
|
it — the walk, with nothing fetched — drives the notice exactly as a
|
|
real one does. It reads the store and never the notice's own record,
|
|
which is why that record is a plain object. */
|
|
$effect(() => { syncShade(); });
|
|
|
|
/* ── the card gets the cover once the thumbnail has landed ───────── */`);
|
|
rep(` bindTransfers();`, ` bindTransfers();
|
|
bindXferNote();`);
|
|
rep(`api, transfers, pls, notice,`, `api, transfers, xfn: xfernote, pls, notice,`);
|
|
save();
|
|
|
|
// ── the screen grows the matching button ───────────────────────────────
|
|
load('src/mobile/screens/Transfers.svelte');
|
|
rep(` import { transfers, DONE, list, active, clearFinished, cancel, stateLine } from "../transfers.svelte.js";`,
|
|
` import { transfers, DONE, list, active, clearFinished, cancel, cancelAll, stateLine } from "../transfers.svelte.js";`);
|
|
rep(` <h1>Transfers</h1>
|
|
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if}`,
|
|
` <h1>Transfers</h1>
|
|
<div class="acts">
|
|
{#if live}<button class="all" onclick={cancelAll}>Cancel all</button>{/if}
|
|
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if}
|
|
</div>`);
|
|
rep(` Save a record or a song from the catalog and it arrives here — the best copy by the download rules, filed into your library when it lands.`,
|
|
` Save a record or a song from the catalog and it arrives here — the best copy by the download rules, filed into your library when it lands.
|
|
A Save keeps going with the app in the background, and says so in the shade — one notice with what is left, and Stop all on it.`);
|
|
save();
|
|
|
|
// ── the head's two words (an append has no anchor to be ambiguous) ─────
|
|
load('src/mobile/shell.css');
|
|
s = s.replace(/\s*$/, NL) + `
|
|
/* The Transfers head's two words: Cancel all while anything is in flight,
|
|
Clear finished after. The .sec's own small caps, which this head never
|
|
had — "Clear finished" has been sitting there at body size.
|
|
|
|
The space between them goes on the words, NOT on .phead .acts: Library
|
|
and Community hang their header icons in that same wrapper and they sit
|
|
flush, so a gap there would push three round buttons apart on two
|
|
screens this round never looked at. */
|
|
.phead .all { font-size: 12.5px; font-weight: 750; color: var(--text-secondary); white-space: nowrap; }
|
|
.phead .all:active { color: #fff; }
|
|
.phead .all + .all { margin-left: 15px; }
|
|
`;
|
|
save();
|
|
|
|
// ── the mocks ──────────────────────────────────────────────────────────
|
|
load('src/lib/mockLibrary.js');
|
|
rep(` if (cmd === "plugin:share|pending_share") {`,
|
|
` /* the downloads notice: the mock walk cannot see the shade, so the calls
|
|
are simply answered — what the walk reads is __ss.xfn, the app's own
|
|
record of having posted and cleared */
|
|
if (cmd === "plugin:transfer-notice|update") return Promise.resolve();
|
|
if (cmd === "plugin:transfer-notice|clear") return Promise.resolve();
|
|
if (cmd === "plugin:share|pending_share") {`);
|
|
save();
|
|
|
|
// ── the walks ──────────────────────────────────────────────────────────
|
|
load('tests/mobile/smoke.mjs');
|
|
rep(` xfers: document.querySelectorAll('.row.xfer').length,`,
|
|
` xfers: document.querySelectorAll('.row.xfer').length,` + NL + ins('ins-r29-probe.txt'));
|
|
/* a step may ask the system for its side as well; dumpsys is slow, so only
|
|
the steps that ask for it pay */
|
|
rep(` const info = await cdp.evaluate(PROBE);`,
|
|
` const info = await cdp.evaluate(PROBE);
|
|
/* a step may want the system's own view as well; dumpsys is slow,
|
|
so only the steps that ask for it pay for it */
|
|
if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } }`);
|
|
/* function declarations hoist, so the steps above may name it */
|
|
rep(`/* The system's view of the app's media session: who owns it and what the`,
|
|
ins('ins-r29-shadenotice.txt') + NL + NL + `/* The system's view of the app's media session: who owns it and what the`);
|
|
rep(` { do: \`__click('.screen .row', 'Transfers')\`, name: "transfers", wait: 1200 },`,
|
|
` { do: \`__click('.screen .row', 'Transfers')\`, name: "transfers", wait: 1200 },` + NL + ins('ins-r29-mock-steps.txt'));
|
|
rep(` transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,`,
|
|
` transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,` + NL + ins('ins-r29-mock-expect.txt'));
|
|
rep(` { do: \`__click('.halfsheet .prompt-acts .btn-fill', 'Save')\`, name: "you-port-restored", wait: 1200 },`,
|
|
` { do: \`__click('.halfsheet .prompt-acts .btn-fill', 'Save')\`, name: "you-port-restored", wait: 1200 },` + NL + ins('ins-r29-phone-steps.txt'));
|
|
rep(` "you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,`,
|
|
` "you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,` + NL + ins('ins-r29-phone-expect.txt'));
|
|
save();
|
|
|
|
// ── the doc ────────────────────────────────────────────────────────────
|
|
load('ANDROID.md');
|
|
rep('| `src/mobile/slsk.svelte.js` | the Soulseek hub:',
|
|
`| \`src/mobile/xfernote.svelte.js\` | the shade's download notice: mirrors what is still in flight into the transfers plugin (throttled, one post per 700 ms of changed content) and carries out its Stop all and Open |
|
|
| \`src/mobile/slsk.svelte.js\` | the Soulseek hub:`);
|
|
rep('| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`,',
|
|
`| \`crates/tauri-plugin-transfer-notice/\` | a second Tauri plugin with a second Kotlin foreground service (\`TransferService\`, \`dataSync\`) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing |
|
|
| \`src/mobile/Sheet.svelte\`, \`HalfSheet.svelte\`,`);
|
|
rep(`## Performance`, ins('ins-r29-doc.txt') + NL + NL + `## Performance`);
|
|
rep(`a catalog record, its Save and the Transfers burst → Gaps → the playlist`,
|
|
`a catalog record, its Save and the Transfers burst (and the shade's notice:
|
|
posted with a count while rows are in flight, gone when they are not) →
|
|
Gaps → the playlist`);
|
|
rep(`key; every CDP call times out after 45 s and the reload step re-attaches`,
|
|
`key — and a step may carry a \`probe\` as well, a function whose reading of
|
|
the system is merged into that step's own expectation, which is how the
|
|
shade's notice is asserted out of \`dumpsys notification\`; the shade steps
|
|
push two rows straight into the transfer store rather than fetching
|
|
anything, on purpose; every CDP call times out after 45 s and the reload step re-attaches`);
|
|
save();
|
|
console.log('round 29 wired');
|