Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
28 changes: 28 additions & 0 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 9 additions & 3 deletions packaging/windows/README-WINDOWS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 4 additions & 0 deletions scripts/windows/make-portable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
10 changes: 9 additions & 1 deletion static/js/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions static/js/main.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 ───
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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(() => {
Expand All @@ -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) => {
Expand Down
34 changes: 34 additions & 0 deletions static/js/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion static/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading