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.
189 lines
11 KiB
JavaScript
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');
|