// 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(`

Transfers

{#if rows.some((d) => DONE.has(d.state))}{/if}`, `

Transfers

{#if live}{/if} {#if rows.some((d) => DONE.has(d.state))}{/if}
`); 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');