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