Portable Windows data dir + auto-clear resolved failure notifications (#402)

* Redirect Windows portable zip cache data to data/ next to the exe

FFmpeg, Demucs models, config, and logs currently write to %LOCALAPPDATA%
regardless of where the zip is extracted, not to the data/ folder the README
already describes. A portable.txt marker, shipped in every future Windows zip,
switches local_data_dir() to the exe-relative data/ folder that packaging
already stages.

Jobs/library data is deliberately left untouched: it stays on its existing
default (~/Documents/StemDeck) and remains relocatable via the existing
Settings -> StemData location picker (#354). Defaulting it into the
exe-adjacent folder was the design in an earlier attempt at this fix, and was
reverted -- that folder is exactly what a user deletes or overwrites thinking
it's disposable.

Fixes #399

* Auto-clear failure notifications once they're resolved

Failure notifications (import/playback/export/update) persist until manually
dismissed, deliberately, from #359 -- so a crash or reload doesn't lose the
evidence needed for a bug report. This adds a second, independent trigger on
top without touching that: a notification also clears once the thing it was
about is actually resolved, while still surviving a plain reload in the
meantime.

- import: clears when a re-import supersedes the failed track, or when the
  track is trashed/purged
- playback: clears when the same track plays back successfully
- export: clears when the same track exports successfully (jobId is
  snapshotted at click time, not read live at settle time, since settling can
  take up to EXPORT_BUSY_MAX_MS and the user may have switched tracks by
  then); log export clears separately, keyed by kind since it has no jobId
- update: clears on the next successful check, which in practice only happens
  on the next app start -- checkForUpdate() has no periodic re-check today

Fixes #401

---------

Co-authored-by: Thales <>
This commit is contained in:
Tha.Les
2026-08-21 01:00:21 +01:00
committed by GitHub
parent 882b23824b
commit 306f2ce913
10 changed files with 213 additions and 11 deletions
+1 -1
View File
@@ -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.
---
+28
View File
@@ -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<PathBuf, String> {
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();
+9 -3
View File
@@ -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).
+4
View File
@@ -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
+18 -1
View File
@@ -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 });
+9 -1
View File
@@ -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.
+16 -4
View File
@@ -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) => {
+34
View File
@@ -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);
+4 -1
View File
@@ -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
+90
View File
@@ -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);