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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user