Files
SoulStream/handoff/rounds/patch-round36.cjs
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

371 lines
23 KiB
JavaScript

// Round thirty-six: one file, picked. The storage plugin had a folder
// picker and an image picker and no way at all to name a FILE, and the
// phone's import sheet has been four doors with a note under it apologising
// for the fifth since round 24. So `pick_file`: the document sheet with the
// caller's own filter, answered with a path Rust can open — the document's
// OWN path where this phone has one (nothing copied, so a 300 MB pick costs
// nothing), a bounded copy into cacheDir/picked under its own name where it
// does not, and the display name handed over separately because that name
// is what decides a playlist's format and its title. Then the fifth door,
// on round 24's screen, wait card and review; `playlist_import_probe` took
// a path and a name the way `playlist_import_folder` took a path.
//
// The brief also named a second consumer — `community_pick_file`, "sending
// a file in a chat". It does not exist: `community_pick_file` is the YOU
// section's three native dialogs (an avatar source, and the .ssrecovery
// artifact), the protocol wrapper has `say_room` and `message_user` and
// nothing that sends a file, and both frontends' thread composers are text
// only. Nothing is wired for it, and nothing is invented.
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 tolerant anchors, both in ANDROID.md, both for the same reason:
playlist EXPORT is named in the same breath as this round's picker twice
over — the "Not on the phone yet" sentence and the "Not done yet" bullet
— and export is a sibling round's line to close. Whichever of the two
rounds ran first, what is left behind has to be true, so each takes
either shape and throws only when neither is there. Every anchor outside
ANDROID.md is hard, and so is every other anchor inside it. */
const either = (a, b, c, d) => {
if (s.includes(a)) rep(a, b);
else if (s.includes(c)) rep(c, d);
else throw new Error('missing (neither shape) in ' + p + ': ' + a.slice(0, 90));
};
/* Several inserts below re-emit their own anchors, and one file is written
whole, so a second run would double-write where `once` cannot see it
coming and only throw further down, leaving half a tree. Refuse one here,
before anything is touched: the plugin's COMMANDS array is what this
round creates and nothing else in the repo makes it. */
if (fs.readFileSync(root + 'crates/tauri-plugin-storage/build.rs', 'utf8').includes('pick_file')) {
throw new Error('crates/tauri-plugin-storage already has pick_file — round 36 has already run against ' + root);
}
// ── the plugin: Kotlin ────────────────────────────────────────────────
load('crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt');
/* Round thirty-five lands before this one and needs the same two imports for
its own document sheet, so take them only if they are not already there —
a file cannot import a name twice for the sake of a second caller. */
if (!s.includes(`import app.tauri.annotation.InvokeArg`)) {
rep(`import app.tauri.annotation.Command`,
`import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg`);
}
if (!s.includes(`import android.provider.OpenableColumns`)) {
rep(`import android.provider.DocumentsContract`,
`import android.provider.DocumentsContract
import android.provider.OpenableColumns`);
}
/* the args class sits at top level, where transfer-notice's NoticeArgs does
— above the plugin's own KDoc, so that comment stays on the class it
describes */
rep(`/**
* The phone's storage for the library: a folder picked through Android's`,
ins('ins-r36-kt-top.txt') + NL + NL + `/**
* The phone's storage for the library: a folder picked through Android's`);
/* the class tail. folderPicked ends with the same two lines, but only
imagePicked's are followed by the class's own closing brace. */
rep(` Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
}`,
` Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
` + ins('ins-r36-kt-body.txt') + NL + `}`);
save();
// ── the plugin: Rust ──────────────────────────────────────────────────
load('crates/tauri-plugin-storage/src/lib.rs');
rep(`//! Three commands: \`pick_folder\` → \`{ uri, path }\` (path null when the tree
//! is not on a volume with a path), \`all_files_access\` → \`{ granted }\`, and
//! \`request_all_files_access\`, which opens the system screen. On the desktop
//! the plugin is registered so the permission set resolves, and every
//! command answers "not here".`, ins('ins-r36-plugin-doc.txt'));
rep(`#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {`, ins('ins-r36-plugin-types.txt') + NL + NL + `#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {`);
rep(`/// A photo from the phone: the document picker limited to images, the`,
ins('ins-r36-plugin-cmd.txt') + NL + NL + `/// A photo from the phone: the document picker limited to images, the`);
rep(` .invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image])`,
` .invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image, pick_file])`);
save();
load('crates/tauri-plugin-storage/build.rs');
rep(`// The three commands, as the Rust functions are named; the ACL slugs them
// (\`allow-pick-folder\`, …) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image"];`,
`// The commands, as the Rust functions are named; the ACL slugs them
// (\`allow-pick-folder\`, …) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image", "pick_file"];`);
save();
// ── the permission set. The three files under autogenerated/ are the
// plugin build script's own output, emitted here verbatim rather than
// hand-written, which is how they drift. ─────────────────────────────
load('crates/tauri-plugin-storage/permissions/default.toml');
rep(`The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), and Android's all-files access —
whether it is granted, and the system screen that grants it.
"""
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image"]`,
`The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), pick a photo or a file (the
document picker, answered with a path — the document's own where this phone
has one, else a bounded copy in the app's cache), and Android's all-files
access — whether it is granted, and the system screen that grants it.
"""
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image", "allow-pick-file"]`);
save();
fs.writeFileSync(root + 'crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_file.toml',
ins('ins-r36-perm-command.txt') + NL);
fs.writeFileSync(root + 'crates/tauri-plugin-storage/permissions/autogenerated/reference.md',
ins('ins-r36-perm-reference.txt') + NL);
/* the schema ends on its last brace, with no newline — the generator's own
shape, and a byte the next real build would otherwise rewrite */
fs.writeFileSync(root + 'crates/tauri-plugin-storage/permissions/schemas/schema.json',
ins('ins-r36-perm-schema.txt'));
// ── the Rust the door lands on: the probe grows a path AND a name, the
// way round 24's folder import grew a path ───────────────────────────
load('src-tauri/src/playlists.rs');
rep(`/// Open a playlist file and stage it for review. Parsing and resolution only —
/// nothing is written until \`playlist_import_commit\`.
#[tauri::command]
pub async fn playlist_import_probe(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
) -> Result<Option<ImportDraft>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter(
"Playlist",
&["m3u", "m3u8", "pls", "xspf", "csv", "json", "txt"],
)
.pick_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(source) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let source_path = PathBuf::from(&source);
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| source.clone());`, ins('ins-r36-probe-head.txt'));
save();
// ── the wrappers ──────────────────────────────────────────────────────
load('src/lib/api.js');
rep(`export const pickImage = () => invoke("plugin:storage|pick_image");`,
`export const pickImage = () => invoke("plugin:storage|pick_image");
// A FILE from the phone (the storage plugin): { path, name, size, copied }.
// \`mimeTypes\` is the document sheet's filter, comma-separated; \`maxCopyBytes\`
// is how big a document may be copied into the app's cache when it has no
// path of its own on this phone — 0 refuses the copy, which is what an
// audio-sized pick wants. The name comes back beside the path because a
// copy is named after the document and only the document's name is it.
export const pickFile = (mimeTypes, maxCopyBytes = 0) =>
invoke("plugin:storage|pick_file", { mimeTypes, maxCopyBytes });`);
rep(`// Import: probe opens a file dialog and stages a reviewed draft; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe");`,
`// Import: probe stages a reviewed draft from a playlist file; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
// The desktop passes neither argument and opens its own dialog; the phone
// picks with the storage plugin and hands the path in — with the picked
// document's own name, which is what chooses the parser and titles the
// draft (a copied file is not always named what the document was).
export const playlistImportProbe = (path = null, name = null) =>
invoke("playlist_import_probe", { path, name });`);
save();
// ── the fifth door ────────────────────────────────────────────────────
load('src/mobile/importer.svelte.js');
rep(` rules, on the doors that need no file picker: a link (YouTube · Spotify ·
Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a
timestamped setlist, or track links — a paste holding one is read as
links), a YouTube DJ set's posted tracklist, and a folder of audio files`,
` rules, on every door it has: a playlist file off this phone (M3U · M3U8 ·
PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple Music ·
TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist,
or track links — a paste holding one is read as links), a YouTube DJ
set's posted tracklist, and a folder of audio files`);
rep(` door: null, // "link" | "text" | "youtube" | "folder"`,
` door: null, // "file" | "link" | "text" | "youtube" | "folder"`);
rep(`/** [door, icon, title, hint] — the desktop's Import menu, minus the
* playlist-file door (that one still wants a file picker). A YouTube
* playlist link goes through "link": the backend dispatches it. */
export const DOORS = [`,
`/** [door, icon, title, hint] — the desktop's Import menu, all five doors.
* A YouTube playlist link goes through "link": the backend dispatches it. */
export const DOORS = [
["file", "file-text", "Playlist file…", "M3U · M3U8 · PLS · XSPF · CSV · JSON"],`);
rep(`/** The folder door picks FIRST — the phone's own document-tree picker — so`,
ins('ins-r36-importer-pick.txt') + NL + `/** The folder door picks FIRST — the phone's own document-tree picker — so`);
rep(`/** Stage a folder of audio files. The backend walks the folder and its disc`,
ins('ins-r36-importer-start.txt') + NL + `/** Stage a folder of audio files. The backend walks the folder and its disc`);
save();
load('src/mobile/HalfSheet.svelte');
rep(` import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";`,
` import { DOORS, start as startImport, pickImportFolder, startFolder, pickImportFile, startFile } from "./importer.svelte.js";`);
rep(` /* the folder door picks FIRST: the review only opens for a folder that`,
ins('ins-r36-halfsheet-door.txt') + NL + ` /* the folder door picks FIRST: the review only opens for a folder that`);
rep(` {@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))}`,
` {@render row(icon, t, m, () => (door === "folder" ? folderDoor() : door === "file" ? fileDoor() : importPrompt(door)))}`);
/* the note that apologised for the missing door says the one true thing
about the door instead: a file copied onto this phone has lost its own
folder, and the rows in it match by filename after that */
rep(` <div class="sec-note" style="padding-bottom: 14px">A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.</div>`,
` <div class="sec-note" style="padding-bottom: 14px">A playlist file's rows are matched against the library on this phone — by path where the file names one, and by filename otherwise, so a file copied here from another machine still finds what you have.</div>`);
save();
load('src/mobile/screens/Import.svelte');
rep(` : importer.door === "folder" ? "Reading the folder…"`,
` : importer.door === "file" ? "Reading the playlist file…"
: importer.door === "folder" ? "Reading the folder…"`);
save();
load('src/mobile/Mobile.svelte');
rep(` import { importer, bindImporter, startFolder } from "./importer.svelte.js";`,
` import { importer, bindImporter, startFolder, startFile } from "./importer.svelte.js";`);
rep(`imp: { startFolder },`, `imp: { startFolder, startFile },`);
save();
// ── the mock: the picker, and the probe behind it ─────────────────────
load('src/lib/mockLibrary.js');
rep(` if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\\\mock\\\\photo.jpg" });`,
` if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\\\mock\\\\photo.jpg" });
/* a document that HAD to be copied, with the two names apart: the cache
file is safeName()'s — every character outside the plugin's allowed
set replaced, so the "#" is an underscore there — and the document's
own is what the draft must be read and titled by. Same on purpose:
if the name stopped travelling, the path's basename would still say
m3u8 and only the title would quietly go wrong, which is the bug. */
if (cmd === "plugin:storage|pick_file") return Promise.resolve({ path: "C:\\\\mock\\\\picked\\\\Warehouse set _3.m3u8", name: "Warehouse set #3.m3u8", size: 2048, copied: true });`);
rep(` if (cmd === "playlist_import_folder") {`,
ins('ins-r36-mock-probe.txt') + NL + ` if (cmd === "playlist_import_folder") {`);
save();
// ── the walks. Both blocks sit INSIDE round 24's import run rather than
// at the array ends, which every sibling round reaches for. ─────────
load('tests/mobile/smoke.mjs');
rep(` impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',`,
` impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
impSrc: (document.querySelector('.imp-src') || {}).textContent || '',`);
/* the share-cancel line is byte-identical in both walks, so the anchor
takes the line after it too */
rep(' { do: `__click(\'.imp-foot .btn-ghost\', null)`, name: "share-cancel", wait: 900 },' + NL
+ ' { do: `document.querySelectorAll(\'.segtabs button\')[0].click()`, wait: 400 },',
' { do: `__click(\'.imp-foot .btn-ghost\', null)`, name: "share-cancel", wait: 900 },' + NL
+ ins('ins-r36-mock-steps.txt') + NL
+ ' { do: `document.querySelectorAll(\'.segtabs button\')[0].click()`, wait: 400 },');
/* "share-cancel" reads identically in both maps, so this anchor takes the
line after it as well */
rep(` "share-cancel": (i) => i.title.startsWith("Your library"),
"cat-album-row":`,
` "share-cancel": (i) => i.title.startsWith("Your library"),
` + ins('ins-r36-mock-expect.txt') + NL + ` "cat-album-row":`);
rep(' { do: `__click(\'.halfsheet .row\', \'Folder of audio files\')`, name: "import-folder-picker", wait: 2600 },' + NL
+ ' { adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },',
' { do: `__click(\'.halfsheet .row\', \'Folder of audio files\')`, name: "import-folder-picker", wait: 2600 },' + NL
+ ' { adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },' + NL
+ ins('ins-r36-phone-steps.txt'));
rep(` "import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,`,
` "import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
` + ins('ins-r36-phone-expect.txt'));
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
rep(`is outside the app's private data and access is missing). Nothing is read
outside the folder the user picked.`,
`is outside the app's private data and access is missing). Nothing is read
outside the folder the user picked.
` + ins('ins-r36-doc-storage.txt'));
rep(` shelf's Import opens four doors — a link (YouTube · Spotify · Apple`,
` shelf's Import opens five doors — a playlist file on this phone (M3U ·
M3U8 · PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple`);
rep(` \`playlist_import_folder\`, which grew a \`path\` argument for it: on the
phone that command had no dialog to open and always answered nothing.`,
` \`playlist_import_folder\`, which grew a \`path\` argument for it: on the
phone that command had no dialog to open and always answered nothing. The
file door does the same through the plugin's \`pick_file\`, and
\`playlist_import_probe\` grew \`path\` AND \`name\` for it — the picked
document's own display name is what \`detect_format\` reads the extension
off and what titles the draft, and a document this phone had no path for
arrives as a cache copy under a sanitized filename. A copied file's rows
still find the library: a relative locator that misses falls back to the
basename index, which is the one thing that reads differently from the
desktop, and the sheet says so.`);
/* export is named beside the file door here, so the sibling round that
ships export rewrites this sentence before this one reaches it — and it
re-wraps the lines when it does. Both shapes are answered, and BOTH
answers end on the line
"yt-dlp, a desktop tool), drag reorder, and Share on SoulStream."
byte for byte, because that line is the drag-reorder round's own anchor
and it is the third round to want this one sentence. Only the clause this
round closes comes out; the rest is left where it was found. */
either(`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: export — it wants a file picker the storage plugin
has still to grow, or a writer for one — 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.`,
`Not on the phone yet: the listener door (it needs
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.`);
rep(`links read a page each, a folder of audio files off the phone, a share
from another app) → the playlist repair (an unknown row relinked from the
library)`,
/* the line "library) → the artwork viewer → …" is left where it is, whole:
the drag-reorder round anchors on it. So the wrap above it absorbs the
new clause instead. */
`links read a page each, a folder of audio files off the phone, a playlist
file through the phone's own document picker — with that picker cancelled
and a file it can hand no path for refused — and a share from another
app) → the playlist repair (an unknown row relinked from the
library)`);
/* and the bullet itself. Its other half is export again: while export is
still waiting, only this round's sentence comes out and the bullet stands.
Once the sibling round has shipped export the bullet is nothing BUT this
sentence, and a "waiting on a picker" with nothing waiting — and a plugin
that "cannot read one" when it can — is worse than no bullet, so the
whole thing goes. */
const BULLET_AFTER_EXPORT = `- **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,
beside the \`pick_folder\` and \`pick_image\` it already has.
`;
const LINE_THIS_ROUND = ` Playlist-FILE import wants a \`pick_file\` in the storage plugin,
beside the \`pick_folder\` and \`pick_image\` it already has.`;
if (s.includes(BULLET_AFTER_EXPORT)) rep(BULLET_AFTER_EXPORT, '');
else if (s.includes(LINE_THIS_ROUND)) rep(LINE_THIS_ROUND, '');
else console.log(' (round 36: the pick_file line had already gone from "Not done yet")');
save();
console.log('round 36 wired');