diff --git a/README.md b/README.md index 4f5ba90a..9ff07f92 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ macOS may show a Gatekeeper prompt on first open — right-click the app and cho | `StemDeck-Windows-x64.zip` | CPU only | ~700 MB | | `StemDeck-Windows-x64.NVIDIA.zip` | NVIDIA CUDA | ~1.6 GB | -Extract the zip anywhere, run `StemDeck.exe`. On first launch the app verifies the bundled Python runtime and downloads FFmpeg and the Demucs model (~170 MB). Subsequent launches skip this and start in seconds. Everything is self-contained; no Python or system dependencies required. +Extract the zip anywhere, run `StemDeck.exe`. FFmpeg, the Demucs model, config, and logs live in a `data/` folder next to `StemDeck.exe`, not in AppData; move or copy the whole extracted folder anywhere and it keeps working. On first launch the app verifies the bundled Python runtime and downloads FFmpeg and the Demucs model (~170 MB) into that folder. Subsequent launches skip this and start in seconds. Everything is self-contained; no Python or system dependencies required. Your job/library data stays in its usual location (`~/Documents/StemDeck` by default) and is relocatable anytime from Settings → StemData location. --- diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 4003c384..c90e7726 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -986,6 +986,15 @@ fn is_cpu_only_package(root: &Path) -> bool { root.join("cpu-only").is_file() } +/// The `portable.txt` marker is trusted ONLY in the app root: it ships next to +/// StemDeck.exe inside the Windows portable zip (scripts/windows/make-portable.ps1), +/// mirroring the `cpu-only` marker's root-only-trust pattern above. Shipped +/// unconditionally in both the CPU and NVIDIA Windows builds, so a fresh +/// extract is portable with zero user action. Never present on macOS/Linux. +fn is_portable_package(root: &Path) -> bool { + root.join("portable.txt").is_file() +} + /// A persisted "cpu-only-package" device decision is only trustworthy while the /// *current* install is still the CPU-only build. When a user replaces the CPU /// package with the NVIDIA (CUDA) build in the same data dir, the leftover @@ -1880,6 +1889,13 @@ fn local_data_dir() -> Result { if let Ok(path) = env::var("STEMDECK_DATA_DIR") { return Ok(PathBuf::from(path)); } + // Windows portable zip: redirect into data/ next to StemDeck.exe instead of + // %LocalAppData% (#399). No-ops on macOS/Linux, where the marker never ships. + if let Ok(root) = app_root() { + if is_portable_package(&root) { + return Ok(root.join("data")); + } + } #[cfg(windows)] { let base = env::var("LOCALAPPDATA") @@ -3752,6 +3768,18 @@ b6052160df96b31c9b1e33854a4dcda3d4b57641b880270f31736fb9f445d384 ffmpeg-n7.1-la assert!(super::is_cpu_only_package(root.path())); } + #[test] + fn portable_marker_trusted_in_root_only() { + let root = make_tmp(); + let data = make_tmp(); + // Marker only in the data dir must NOT mark this package portable. + fs::write(data.path().join("portable.txt"), "").unwrap(); + assert!(!super::is_portable_package(root.path())); + // Marker in the app root (ships with the package) does. + fs::write(root.path().join("portable.txt"), "").unwrap(); + assert!(super::is_portable_package(root.path())); + } + #[test] fn stale_data_dir_marker_is_removed_for_gpu_builds() { let root = make_tmp(); diff --git a/packaging/windows/README-WINDOWS.txt b/packaging/windows/README-WINDOWS.txt index 968658be..b17a30e9 100644 --- a/packaging/windows/README-WINDOWS.txt +++ b/packaging/windows/README-WINDOWS.txt @@ -13,13 +13,19 @@ Notes - This is a portable folder, not an installer. - No Start Menu shortcut, service, or registry integration is created. -- Generated files stay under data/. +- Runtime, config, and logs stay under data/, next to StemDeck.exe, not in AppData. - FFmpeg is downloaded during first-run setup into data/ffmpeg/. - Demucs model weights are downloaded by the backend on first use into data/models/. +- Your job history and library are stored separately (Documents/StemDeck by default, + same as before), not inside this folder -- relocate them anytime from + Settings -> StemData location if you'd rather keep them elsewhere. +- The empty portable.txt file next to StemDeck.exe is what tells the app to use + this data/ folder instead of AppData -- don't delete it. Troubleshooting --------------- - If setup fails, check internet access and retry. -- If a job fails, inspect data/jobs/ and data/logs/ when logs are added. -- Deleting data/ forces first-run setup to recreate runtime state. +- If a job fails, inspect data/logs/ when logs are added. +- Deleting data/ forces first-run setup to recreate runtime state (ffmpeg and the + Demucs model re-download; your job history and library are unaffected). diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 0111b472..286a2711 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -168,6 +168,10 @@ New-Item -ItemType Directory -Force (Join-Path $Stage "data") | Out-Null foreach ($Dir in @("cache", "downloads", "ffmpeg", "jobs", "logs", "models")) { New-Item -ItemType Directory -Force (Join-Path $Stage "data\$Dir") | Out-Null } +# Portable marker: present in every zip (CPU and NVIDIA alike) so double- +# clicking StemDeck.exe uses .\data next to the exe for ffmpeg/models/config/ +# logs instead of AppData (#399). Root-only trust, mirroring cpu-only below. +New-Item -ItemType File -Force (Join-Path $Stage "portable.txt") | Out-Null if ($CpuOnly) { # Root marker only: the app trusts cpu-only solely in the app root (#247). # A data\cpu-only copy used to leak into the shared per-user data dir and diff --git a/static/js/catalog.js b/static/js/catalog.js index eb84d352..2e07f03d 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -10,7 +10,7 @@ import { startQueueStream, } from "./queue.js"; import { fmtTime, storeGet, storeSet } from "./utils.js"; -import { notifyFailure, setReleasePending } from "./notifications.js"; +import { notifyFailure, setReleasePending, dismissFailuresByJobId, dismissFailuresByKind } from "./notifications.js"; // Escape user-supplied strings before inserting into innerHTML. function esc(s) { @@ -193,6 +193,10 @@ function purgeTrash() { folder.items = folder.items.filter((id) => !trashIds.has(id)); } trash.items = []; + // Catches a job that errored after its track was trashed but before this + // permanent delete — moveTrackToTrash's own dismiss already fired earlier + // and can't have caught a failure that didn't exist yet (#401). + for (const id of trashIds) dismissFailuresByJobId(id); return true; } @@ -272,6 +276,9 @@ export function addTrackToLibrary(track) { } else { replaceTrackId(existingId, track.id); } + // The old track is gone either way (deleted or replaced) — any failure + // notification tied to it, whichever kind, is moot now (#401). + dismissFailuresByJobId(existingId); } const existing = tracks[track.id] || {}; tracks[track.id] = { @@ -503,6 +510,10 @@ function moveTrackToTrash(trackId) { const trash = getTrashFolder(); if (trash && !trash.items.includes(trackId)) trash.items.unshift(trackId); if (_currentTrackId === trackId) _currentTrackId = null; + // The user is done with this track — clear any failure tied to it (#401). + // purgeTrash() does the same on permanent delete, for a job that errors + // after being trashed but before it's purged. + dismissFailuresByJobId(trackId); saveState(); render(); } @@ -2283,6 +2294,9 @@ async function checkForUpdate() { try { const res = await fetch(RELEASES_API, { headers: { Accept: "application/vnd.github+json" } }); if (!res.ok) return; + // The check itself succeeded, regardless of what it finds below — clear + // any stale "update check failed" card (#401). + dismissFailuresByKind("update"); const data = await res.json(); const latest = normalizeVersion(data.tag_name); // Compare canonically so a PEP440 current version (0.7.0a9) matches the @@ -3042,6 +3056,9 @@ async function exportLogs(btn) { a.click(); a.remove(); URL.revokeObjectURL(url); + // Log export isn't tied to a track/job, so a plain by-kind dismiss is the + // right granularity — it never touches a per-track export failure (#401). + dismissFailuresByKind("export"); } catch (e) { console.warn("[settings] log export failed:", e); showError("Could not export the logs.", null, { retry: false }); diff --git a/static/js/job.js b/static/js/job.js index fc38dc54..359185cf 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -7,7 +7,7 @@ import { selectedStems, } from "./state.js"; import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js"; -import { notifyFailure } from "./notifications.js"; +import { notifyFailure, dismissFailuresByJobId } from "./notifications.js"; import { stagePhrases } from "./phrases.js"; import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js"; import { initSections } from "./sections.js"; @@ -147,6 +147,14 @@ export function clearPlaybackError() { if (errorEl.dataset.kind === "playback") clearImportError(); } +// Playback actually succeeded for this track — clear any stale "playback +// failed" notification for it (#401). Only the playback kind: a successful +// play doesn't mean an unrelated import/export failure for the same track +// is resolved too. +export function resolvePlaybackSuccess(jobId) { + if (jobId) dismissFailuresByJobId(jobId, "playback"); +} + // Clear the import chrome (progress box, error, phrase rotation, foreground // SSE) without touching the studio. Split out of reset() so a submit that goes // to the back of the queue does not tear down audio the user is playing. diff --git a/static/js/main.js b/static/js/main.js index 72bdc677..d9af5579 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,6 +1,7 @@ import { playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd, setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady, + currentJobId, } from "./state.js"; import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js"; @@ -10,7 +11,7 @@ import { wireBeatGridUi } from "./beatgridUi.js"; import { togglePlayPause, updateLoopRegionVisual, toggleMetronome } from "./transport.js"; import { wireStemListControls, wireMixerToolbar } from "./mixer.js"; import { initCatalog, collectDiagnostics } from "./catalog.js"; -import { initNotifications, notifyFailure } from "./notifications.js"; +import { initNotifications, notifyFailure, dismissFailuresByJobId } from "./notifications.js"; import { runStoreMigrationIfNeeded } from "./utils.js"; // ─── Stem choice toggles on the import page ─── @@ -238,7 +239,11 @@ function wireFooterControls() { // `picking` covers the gap between the click and the transfer: the dialog is // app-modal so the menu is unreachable anyway, but the flag keeps a second // export from being queued behind it without lying about the label. - function settleBusy(pending) { + // jobId is snapshotted by the caller at click time, not read live here: + // settlement can take up to EXPORT_BUSY_MAX_MS, by which point the user may + // have opened a different track, and currentJobId would then point at the + // wrong one (#401). + function settleBusy(pending, jobId) { const token = ++busyToken; const finish = () => { picking = false; @@ -251,6 +256,12 @@ function wireFooterControls() { } const backstop = window.setTimeout(finish, EXPORT_BUSY_MAX_MS); pending + .then((ok) => { + // ok === false means the save dialog was cancelled, not a real + // export — nothing was resolved, so leave any failure notification + // in place rather than clearing it on a no-op. + if (jobId && ok !== false) dismissFailuresByJobId(jobId, "export"); + }) .catch((err) => { // A cancelled dialog resolves false without ever entering the busy // state, so anything here is a real failure. @@ -260,7 +271,7 @@ function wireFooterControls() { kind: "export", message, detail: err instanceof Error ? String(err.message) : null, - context: { stage: `Exporting ${format}` }, + context: { stage: `Exporting ${format}`, jobId }, }); }) .finally(() => { @@ -274,13 +285,14 @@ function wireFooterControls() { function runExport(start, emptyMessage) { if (busy || picking) return; picking = true; + const jobId = currentJobId; // snapshot now -- see settleBusy's comment const pending = start(enterBusy); if (!pending) { picking = false; showError(emptyMessage, null, { retry: false }); return; } - settleBusy(pending); + settleBusy(pending, jobId); } exportBtn?.addEventListener("click", (e) => { diff --git a/static/js/notifications.js b/static/js/notifications.js index 830c5a4a..7a8a1b37 100644 --- a/static/js/notifications.js +++ b/static/js/notifications.js @@ -268,12 +268,46 @@ export function dismissFailure(id) { render(); } +/** Clear failures tied to a specific job/track id, because whatever the + * failure was about reached a resolved state -- retried successfully, or + * ceased to exist. kind=null clears every kind for that id (the id itself + * is gone, so any failure tagged with it is moot); a specific kind clears + * only that action's record, so e.g. a resolved playback failure can't + * silently swallow an unresolved import failure for the same track. */ +export function dismissFailuresByJobId(jobId, kind = null) { + if (!jobId) return; + const before = failures.length; + failures = failures.filter((f) => !(f.context?.jobId === jobId && (kind === null || f.kind === kind))); + if (failures.length === before) return; // nothing changed -- skip the write/render + persist(); + render(); +} + +/** Clear id-less failures of one kind (update checks, log exports -- neither + * is ever tied to a job). Never touches a failure that carries a jobId, even + * if it shares this kind -- a per-track export failure can only be cleared + * via dismissFailuresByJobId for that track. */ +export function dismissFailuresByKind(kind) { + const before = failures.length; + failures = failures.filter((f) => !(f.kind === kind && !f.context?.jobId)); + if (failures.length === before) return; + persist(); + render(); +} + export function clearFailures() { failures = []; persist(); render(); } +/** Test-only accessor: `failures` is module-private and render() writes to a + * DOM that doesn't exist under plain Node, so this is the only way a test can + * assert on notification state. */ +export function getFailures() { + return failures; +} + /** catalog.js tells us whether an update card is showing, for badge/empty. */ export function setReleasePending(pending) { releasePending = Boolean(pending); diff --git a/static/js/player.js b/static/js/player.js index 56c553f2..4b375d39 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -4,7 +4,7 @@ import { fmtTime } from "./utils.js"; // the same) and safe: these are only ever called from a callback, long after both // module bodies have run, and they close over DOM handles from dom.js rather than // job.js state. -import { showPlaybackError, clearPlaybackError } from "./job.js"; +import { showPlaybackError, clearPlaybackError, resolvePlaybackSuccess } from "./job.js"; import { STEM_NAMES, TRACK_NAMES, STEM_COLORS, PROGRESS_COLOR, LOOP_DEFAULT_START_FRAC, LOOP_DEFAULT_END_FRAC, LANE_VOLUME_MAX, @@ -1299,6 +1299,9 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti ); return; } + // Playback actually came up for this track — clear a stale + // "playback failed" notification if one was sitting there (#401). + resolvePlaybackSuccess(jobId); eng.setLoop(loopEnabled, loopStart, loopEnd); applyMix(); // push per-stem gains (incl. >1.0 boost) into the engine diff --git a/tests/js/notifications-resolve.test.mjs b/tests/js/notifications-resolve.test.mjs new file mode 100644 index 00000000..a899fd95 --- /dev/null +++ b/tests/js/notifications-resolve.test.mjs @@ -0,0 +1,90 @@ +// Regression test for #401: a failure notification should auto-clear once +// whatever it was about is resolved (job superseded/removed, retried +// successfully), without touching the #359 behavior of surviving a plain +// reload. Covers dismissFailuresByJobId and dismissFailuresByKind directly. +// +// Run: node tests/js/notifications-resolve.test.mjs + +// notifyFailure/dismissFailure* touch window/document (persist -> storeSet, +// render -> DOM); stub both before import, matching wav-header.test.mjs's +// precedent. storeGet/storeSet fall through to a caught localStorage access +// under this stub, so nothing throws. +globalThis.window = {}; +globalThis.document = { getElementById: () => null, querySelectorAll: () => [] }; + +const { + notifyFailure, + dismissFailuresByJobId, + dismissFailuresByKind, + clearFailures, + getFailures, +} = await import("../../static/js/notifications.js"); + +let pass = 0, fail = 0; +const check = (name, cond, detail = "") => { + if (cond) { pass++; console.log(`PASS ${name}`); } + else { fail++; console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); } +}; + +const kinds = () => getFailures().map((f) => f.kind).sort(); + +// -------------------------------------------------------------------------- +// dismissFailuresByJobId +// -------------------------------------------------------------------------- + +clearFailures(); +notifyFailure({ kind: "import", message: "import broke", context: { jobId: "job-1" } }); +notifyFailure({ kind: "playback", message: "playback broke", context: { jobId: "job-1" } }); +notifyFailure({ kind: "import", message: "other track broke", context: { jobId: "job-2" } }); + +dismissFailuresByJobId("job-1", "playback"); +check( + "by jobId+kind clears only the matching kind for that id", + JSON.stringify(kinds()) === JSON.stringify(["import", "import"]), + JSON.stringify(kinds()), +); + +dismissFailuresByJobId("job-1"); +check( + "by jobId (no kind) clears every kind for that id", + JSON.stringify(kinds()) === JSON.stringify(["import"]), + JSON.stringify(kinds()), +); +check("unrelated job's failure survives", getFailures().some((f) => f.context?.jobId === "job-2")); + +clearFailures(); +dismissFailuresByJobId(null); +check("dismissFailuresByJobId(null) is a no-op, does not throw", getFailures().length === 0); + +// -------------------------------------------------------------------------- +// dismissFailuresByKind +// -------------------------------------------------------------------------- + +clearFailures(); +notifyFailure({ kind: "update", message: "check failed" }); +notifyFailure({ kind: "export", message: "log export failed" }); // no jobId +notifyFailure({ kind: "export", message: "track export failed", context: { jobId: "job-3" } }); + +dismissFailuresByKind("export"); +check( + "by kind never touches a record that carries a jobId", + getFailures().some((f) => f.kind === "export" && f.context?.jobId === "job-3"), +); +check( + "by kind clears the id-less record of that kind", + !getFailures().some((f) => f.kind === "export" && !f.context?.jobId), +); +check("unrelated kind survives", getFailures().some((f) => f.kind === "update")); + +// -------------------------------------------------------------------------- +// No-op guard: nothing to clear leaves the list untouched +// -------------------------------------------------------------------------- + +clearFailures(); +notifyFailure({ kind: "import", message: "still broken", context: { jobId: "job-4" } }); +dismissFailuresByJobId("job-does-not-exist"); +dismissFailuresByKind("playback"); +check("no-op dismisses leave unrelated failures in place", getFailures().length === 1); + +console.log(`\n${pass}/${pass + fail} checks passed`); +process.exit(fail ? 1 : 0);