Files
SoulStream/handoff/rounds/patch-round35.cjs
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
2026-09-14 07:57:41 -05:00

189 lines
11 KiB
JavaScript

// Round thirty-five: a playlist, out of the phone. The desktop has had
// Export since playlists v2 and the phone has never had a door onto it,
// for one honest reason — `tauri-plugin-dialog` DOES open Android's
// create-document sheet, but it answers with a `content://` URI and
// `playlist_export` writes with `std::fs::write`, which cannot take one.
// So: a `create_document` in the storage plugin whose Kotlin writes the
// bytes through the ContentResolver, the export restructured so the file
// is built by `playlist_formats` ABOVE the platform fork (the phone must
// never rebuild a format — two exports that drift are worse than one that
// is missing), and the desktop's five profiles on the playlist's own
// sheet, in the desktop's order and its own words. `create_document` is a
// plugin function called from RUST, not a command: the file never passes
// through the WebView, so build.rs, the permissions and the capability are
// all untouched.
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); };
/* One whole function goes, doc comment and all — too long to spell out
twice, so it is cut between two anchors `once` has vouched for. */
const cut = (from, to, b) => {
const i = once(from);
const j = once(to);
if (j < i) throw new Error('end before start in ' + p + ': ' + from.slice(0, 60));
s = s.slice(0, i) + b + s.slice(j + to.length);
};
const NL = String.fromCharCode(10);
/* Several of the anchors below are re-emitted (the sleep branch, the two
walk-array ends), so a second run would double-insert where `once`
cannot see it coming and only throw further down, leaving half a tree.
This round creates no new file, so the guard is what it adds to the one
crate nothing else in the repo touches. */
if (fs.readFileSync(root + 'crates/tauri-plugin-storage/src/lib.rs', 'utf8').includes('create_document')) {
throw new Error('tauri-plugin-storage already has create_document — round 35 has already run against ' + root);
}
// ── the plugin: a document written where the user points ──────────────
/* Not a command, so `invoke_handler`, build.rs's COMMANDS, permissions/
and src-tauri/capabilities are all DELIBERATELY untouched: the ACL gates
`invoke`, and nothing here is invoked from the WebView. */
load('crates/tauri-plugin-storage/src/lib.rs');
rep(`pub fn init<R: Runtime>() -> TauriPlugin<R> {`,
ins('ins-r35-storage-rs.txt') + NL + NL + `pub fn init<R: Runtime>() -> TauriPlugin<R> {`);
save();
// ── the Kotlin half. UNBUILT: nothing in this session can compile it —
// it is written against pickImage/imagePicked next door and against
// TransferNoticePlugin's @InvokeArg/parseArgs pair, and no further.
load('crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt');
rep(`import android.provider.Settings`,
`import android.provider.OpenableColumns
import android.provider.Settings`);
rep(`import app.tauri.annotation.TauriPlugin`,
`import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin`);
/* before the class KDoc, never between a KDoc and what it documents */
rep(`/**
* The phone's storage for the library:`,
ins('ins-r35-kt-args.txt') + NL + NL + `/**
* The phone's storage for the library:`);
/* a blank line ahead of it: the file has none before pickImage's KDoc, and
two members running together is the one thing it does not do elsewhere */
rep(` /** A photo from the phone: the document picker limited to images, the`,
NL + ins('ins-r35-kt-command.txt') + NL + NL + ` /** A photo from the phone: the document picker limited to images, the`);
save();
// ── the export itself: one command, restructured around the fork ──────
load('src-tauri/src/playlists.rs');
cut(`/// Export a playlist through a save dialog. \`format\` ∈ m3u8 | xspf | csv |`,
` Ok(Some(dest))${NL}}`,
ins('ins-r35-export-rs.txt'));
save();
// ── the wrapper's comment. No new wrapper: JS never calls the plugin ──
load('src/lib/api.js');
rep(`// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
// the written path, or null when the dialog was cancelled.`,
`// Export through whichever save door the platform has; format ∈ m3u8 |
// xspf | pls | csv | json. Resolves to where the file went — the written
// path on the desktop, the document's name on the phone (Android's
// create-document sheet answers a content:// URI, not a path) — or null
// when that sheet was dismissed.`);
save();
// ── the store: the desktop's five, and the one call ───────────────────
load('src/mobile/playlists.svelte.js');
rep(`import { note } from "./notice.svelte.js";`,
`import { note } from "./notice.svelte.js";
import { basename } from "./util.js";`);
rep(`/* ── reorder: a row up, down or to the top; the complete id order goes`,
ins('ins-r35-store.txt') + NL + NL + `/* ── reorder: a row up, down or to the top; the complete id order goes`);
save();
// ── the door: the playlist's own sheet, and the sheet it opens ────────
load('src/mobile/HalfSheet.svelte');
rep(`coverFromPhoto, coverClear } from "./playlists.svelte.js";`,
`coverFromPhoto, coverClear, EXPORTS, exportPlaylist } from "./playlists.svelte.js";`);
/* unguarded by `kind`, so the shelf card's sheet carries it too — the
desktop has Export on BOTH menus (Playlists.svelte and Playlist.svelte) */
rep(` <div class="sheet-hair"></div>
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}`,
` <div class="sheet-hair"></div>
{@render row("export", "Export…", p.track_count + " track" + (p.track_count === 1 ? "" : "s") + ", as a playlist file", () => openHalf("plexport", { playlist: p }))}
<div class="sheet-hair"></div>
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}`);
rep(` {:else if kind === "sleep"}`,
ins('ins-r35-sheet.txt') + NL + ` {:else if kind === "sleep"}`);
save();
// ── the mock: where the file went, and nothing else ───────────────────
load('src/lib/mockLibrary.js');
rep(` if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));`,
` if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));
` + ins('ins-r35-mock.txt'));
save();
// ── the walks. The mock's two anchors are the ARRAY ENDS, so this block
// lands after round thirty-four's fresh install put a provisioned
// device back — which is exactly the device it wants.
load('tests/mobile/smoke.mjs');
rep(`];
const MOCK_EXPECT = {`, ins('ins-r35-mock-steps.txt') + NL + `];
const MOCK_EXPECT = {`);
rep(`};
/* the phone walk: a record by name, played for real */`,
ins('ins-r35-mock-expect.txt') + NL + `};
/* the phone walk: a record by name, played for real */`);
/* the phone leg rides the "Walk test" playlist the walk itself makes and
deletes two steps later; nothing is written to Kyle's storage */
rep(` { do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, name: "playlist-more", wait: 800 },`,
ins('ins-r35-phone-steps.txt') + NL + ` { do: \`document.querySelectorAll('.gbar .iconbtn')[1].click()\`, name: "playlist-more", wait: 800 },`);
rep(` end: (i) => /^(Good |Still)/.test(i.title),`,
ins('ins-r35-phone-expect.txt') + NL + ` end: (i) => /^(Good |Still)/.test(i.title),`);
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
rep(`- \`pick_folder\` answers "nothing picked" (desktop-only dialog); \`save_file\` works;`,
`- \`pick_folder\` answers "nothing picked" (desktop-only dialog); \`save_file\`
opens Android's create-document sheet, but answers a \`content://\` URI that
\`std::fs\` cannot open — which is why the playlist export goes through the
storage plugin's \`create_document\` instead;`);
rep(`- **The share sheet** (\`crates/tauri-plugin-share\`, \`share.svelte.js\`):`,
ins('ins-r35-doc-export.txt') + NL + `- **The share sheet** (\`crates/tauri-plugin-share\`, \`share.svelte.js\`):`);
rep(`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.`,
`Not on the phone yet: the playlist-file door — it wants 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.`);
rep(`it answered), then back to the provisioned device, asserts what each step should show`,
`it answered), then back to the provisioned device, and after that the
playlist export door three ways (a file written where the sheet pointed, a
dismissed sheet that says nothing at all, and a refusal read back as a
sentence), asserts what each step should show`);
/* nine named weather steps become twelve: the dismissed sheet (`answer`),
the refusal (`fail`) and the one that reads the weather back off
(`clear`) — the same three roles `weather-clear` was counted in */
rep(`Nine mock steps drive them`, `Twelve mock steps drive them`);
/* the last line of that bullet — "beside the `pick_folder` and `pick_image`
it already has" — is left BYTE-IDENTICAL so a sibling round adding
`pick_file` still finds its anchor. It is now incomplete (create_document
is there too); incomplete beats breaking the chain. */
rep(`- **Waiting on Rust, not on a picker.** Playlist EXPORT: \`tauri-plugin-dialog\`
does open Android's create-document sheet from Rust, but it answers with a
\`content://\` URI and \`playlist_export\` writes with \`std::fs::write\`, which
cannot take one. It needs a ContentResolver write, or the bytes handed to
an outbound share intent — and \`crates/tauri-plugin-share\` is inbound-only
today. Playlist-FILE import wants a \`pick_file\` in the storage plugin,`,
`- **Waiting on a picker.** The storage plugin can WRITE a document now
(\`create_document\`, which the playlist export goes through) but it cannot
read one. Playlist-FILE import wants a \`pick_file\` in the storage plugin,`);
save();
console.log('round 35 wired');