From c4934f868c10cea79aedd5ba2e1f00265aa18600 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 08:48:13 +0100 Subject: [PATCH 1/8] feat(windows): leaner portable package and opt-in in-app updater (#421) Issue #421 asked for Python embedded in a single EXE so updating would not mean copying ~20k loose files over an existing install. A onefile EXE is not viable for this stack (onefile modes re-extract the whole multi-GB payload on every launch, and torch/onnxruntime fight frozen-import hooks), so this addresses the root cause instead: ship less, and stop making users hand-copy a full zip for a release that only changed app code. Leaner package (make-portable.ps1): - Stripping is now unconditional. The -StripVenv opt-in gate was a silent regression risk: nothing stopped a future workflow edit from shipping the unstripped venv with no error. - Also strips stdlib base/Lib/test and per-package test/tests dirs. - Deliberately does NOT strip .dist-info/RECORD. pip needs it to replace a package, and install_cuda_torch pip-installs into this venv on every NVIDIA machine at first run; removing it yields "Failed to uninstall ... missing RECORD file". - Adds a post-strip import check so an over-aggressive strip fails the build rather than a release. Updater (main.rs, catalog.js): - New commands installed_runtime_id, download_app_update, apply_app_update. - Opt-in: the check on launch is unchanged, but download and apply are each an explicit click. It never auto-applies and never interrupts a running job. - Replaces StemDeck.exe and backend/ only. python/ is never touched, because an NVIDIA install rewrites it with CUDA torch at first run and torchDeviceSettled skips ensure_torch_device once the device is cuda, so swapping the directory would silently drop that machine to CPU with no recovery. - The runtime id (uv.lock + interpreter major.minor) is a compatibility gate, not a download trigger: if a release changed the Python dependency set the updater stands down and points at the full download. Only 19 of the last 200 commits touch uv.lock, so the fast path covers most releases. - apply_app_update stages and validates everything before any destructive rename, stops the backend synchronously first (the existing stop_backend returns before the process dies, which would have made every update fail on Windows), and retries renames past transient AV/indexer handles. - Known gap, documented in code: the two exe renames are not atomic. A hard crash in that window leaves StemDeck.exe.old needing a manual rename. Closing it needs a bootstrap launcher that is never itself replaced. CI publishes -app.zip, its .sha256 and -runtime-version.json alongside the unchanged full zips. Fresh installs are unaffected. i18n: the 5 new strings are translated into all 7 language tables, not just English. t() falls back to English silently, so an English-only key looks correct in testing and ships untranslated to six locales. Verified: Windows and Linux (WSL) both compile clean with no new clippy warnings, 39 Rust tests pass on both, JS suites pass, ruff clean. Two new unit tests pin the JSON contract between the PowerShell writer and the Rust reader. Not yet verified: no end-to-end run against a real release. --- .github/workflows/windows-release.yml | 8 +- desktop/src-tauri/src/main.rs | 350 ++++++++++++++++++++++++++ scripts/windows/make-portable.ps1 | 107 ++++++-- static/css/daw.css | 17 ++ static/index.html | 10 + static/js/catalog.js | 177 ++++++++++++- static/js/i18n.js | 35 +++ 7 files changed, 677 insertions(+), 27 deletions(-) diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index b9ec7fe..a5c9ed4 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -53,8 +53,7 @@ jobs: run: | powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` -PackageName StemDeck-Windows-x64.NVIDIA ` - -PackageVersion "$env:REF_NAME" ` - -StripVenv + -PackageVersion "$env:REF_NAME" - name: build Windows CPU run: | @@ -62,7 +61,7 @@ jobs: -PackageName StemDeck-Windows-x64 ` -PackageVersion "$env:REF_NAME" ` -CpuOnly ` - -StripVenv + -PublishUpdaterAssets - name: scan artifacts run: | @@ -101,3 +100,6 @@ jobs: dist/StemDeck-Windows-x64.NVIDIA.zip.sha256 dist/StemDeck-Windows-x64.zip dist/StemDeck-Windows-x64.zip.sha256 + dist/StemDeck-Windows-x64-app.zip + dist/StemDeck-Windows-x64-app.zip.sha256 + dist/StemDeck-Windows-x64-runtime-version.json diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 16fd845..666cd28 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -19,6 +19,10 @@ use tauri_plugin_store::StoreExt; use zip::ZipArchive; const SETUP_VERSION: u64 = 1; +// Where the in-app updater stages its download before applying it (#421). +// Shared between the download and apply halves, hence a constant. +#[cfg(windows)] +const UPDATE_APP_ARCHIVE: &str = "stemdeck-update-app.zip"; // Windows FFmpeg comes from BtbN's GitHub build (served via GitHub's CDN, far // faster worldwide than the old gyan.dev single mirror -- #248). Unlike gyan.dev, // which published a per-file `{url}.sha256` companion, BtbN publishes ONE combined @@ -191,6 +195,28 @@ struct RuntimeArchive { size: u64, } +/// The app-layer artifact to install, resolved by the frontend from the GitHub +/// Releases API (the same check already in static/js/catalog.js) and handed to +/// `download_app_update`. Rust downloads, verifies and applies; it does not +/// re-resolve "what is the latest version" itself. +/// +/// There is no runtime artifact here on purpose. The updater replaces +/// StemDeck.exe and backend/ only -- python/ is never touched, because an +/// NVIDIA install rewrites it with CUDA torch at first run and replacing the +/// directory would silently drop that machine back to CPU. The frontend gates +/// on the release's runtime id first, and falls back to the full-package +/// download whenever the Python dependency set changed. +// Only the Windows build reads these fields; the other platforms keep the +// struct so download_app_update has one signature everywhere and can answer +// with a clear "not available here" rather than a missing-command error. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(not(windows), allow(dead_code))] +struct AppUpdatePlan { + app_url: String, + app_sha256: String, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct DownloadProgress { @@ -271,6 +297,23 @@ fn main() { // moved their library to another disk back to the default // folder, to an empty app with their stems stranded. prune_runtime_leftovers(&data_dir); + // Sweep what an in-app update left at the app root: the + // previous backend/ and exe, plus a staging directory if the + // update was interrupted before it could clean up. The new + // files are already in place either way, so these are only + // ever the old version's leftovers (#421). + if let Ok(root) = app_root() { + for name in ["backend.old", "_update_app.tmp"] { + let stale = root.join(name); + if stale.is_dir() { + let _ = fs::remove_dir_all(&stale); + } + } + let stale_exe = root.join("StemDeck.exe.old"); + if stale_exe.is_file() { + let _ = fs::remove_file(&stale_exe); + } + } let manifest = app_root() .ok() .and_then(|root| load_runtime_manifest(&root).ok()); @@ -310,6 +353,9 @@ fn main() { download_runtime_pack, verify_runtime_pack, extract_runtime_pack, + installed_runtime_id, + download_app_update, + apply_app_update, ensure_external_assets, ensure_torch_device, warmup_models, @@ -735,6 +781,277 @@ fn extract_runtime_pack() -> Result { runtime_pack_status() } +/// The Python dependency-set id of the runtime currently on disk, written into +/// `python/runtime-version.json` by make-portable.ps1. `None` when the marker +/// is absent -- a pre-#421 install, a non-Windows build, or a source checkout. +/// +/// The frontend compares this against the release's published runtime id and +/// only offers an in-app update when they match, since the updater cannot +/// replace python/ (see `AppUpdatePlan`). `None` is treated as "cannot verify", +/// which sends the user to the full-package download rather than risking an app +/// layer whose imports the installed runtime may not satisfy. +#[tauri::command] +fn installed_runtime_id() -> Option { + let root = app_root().ok()?; + let text = fs::read_to_string(root.join("python").join("runtime-version.json")).ok()?; + parse_runtime_id(&text) +} + +/// Split from the command above so the marker's on-disk contract -- the exact +/// shape make-portable.ps1 writes -- is unit-testable without an app root. +fn parse_runtime_id(text: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + value.get("runtimeId")?.as_str().map(|s| s.to_string()) +} + +/// Downloads and checksum-verifies the app-layer update. Windows-only: the +/// portable zip is the only distribution shaped for an in-place file swap. +#[tauri::command] +async fn download_app_update( + plan: AppUpdatePlan, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + #[cfg(not(windows))] + { + let _ = (plan, app_handle); + Err("in-app updates are only available on Windows portable installs".to_string()) + } + #[cfg(windows)] + { + let data_dir = local_data_dir()?; + let downloads = data_dir.join("downloads"); + fs::create_dir_all(&downloads) + .map_err(|e| format!("failed to create {}: {e}", downloads.display()))?; + + let app_archive = downloads.join(UPDATE_APP_ARCHIVE); + // Drop any archive left by an earlier, abandoned download so apply can + // never install something the current plan did not ask for. + let _ = fs::remove_file(&app_archive); + + download_file_with_progress(&plan.app_url, &app_archive, &app_handle).await?; + verify_update_sha256(&app_archive, &plan.app_sha256, "app update") + } +} + +/// Verify a freshly downloaded update archive against its expected SHA256 +/// (from the release's own published `.sha256` companion file, resolved by +/// the frontend) before it is ever extracted. On mismatch the file is removed +/// so a corrupt or tampered download can never be applied. +#[cfg(windows)] +fn verify_update_sha256(path: &Path, expected: &str, label: &str) -> Result<(), String> { + let actual = sha256_file(path)?; + if !actual.eq_ignore_ascii_case(expected.trim()) { + let _ = fs::remove_file(path); + return Err(format!( + "{label} archive checksum mismatch (expected {expected}, got {actual}). \ + The download may be corrupt or tampered. Click Retry to try again." + )); + } + Ok(()) +} + +#[cfg(windows)] +fn extract_zip_archive(archive: &Path, destination: &Path) -> Result<(), String> { + let file = fs::File::open(archive) + .map_err(|e| format!("failed to open {}: {e}", archive.display()))?; + let mut zip = ZipArchive::new(file) + .map_err(|e| format!("failed to read zip {}: {e}", archive.display()))?; + zip.extract(destination) + .map_err(|e| format!("failed to extract {}: {e}", archive.display())) +} + +/// Stops the backend and waits for the process to actually exit. +/// +/// The regular `stop_backend` hands the kill to a background thread and +/// returns immediately -- fine on window close, wrong here. The backend runs +/// *from* the very directories the update is about to rename: its interpreter +/// is `python/`, its code is `backend/`. Windows refuses to rename a directory +/// while a handle inside it is open, so starting the swap before the process +/// is gone fails with a permission error, or worse, part-way through. +#[cfg(windows)] +fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(), String> { + let handles = match state.inner.lock() { + Ok(mut guard) => guard.handles.take(), + Err(_) => return Err("backend state is unavailable".to_string()), + }; + let Some(mut handles) = handles else { + return Ok(()); + }; + let _ = handles.child.kill(); + let deadline = Instant::now() + timeout; + loop { + match handles.child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => { + if Instant::now() >= deadline { + return Err( + "the audio backend did not shut down in time; update cancelled".to_string(), + ); + } + thread::sleep(Duration::from_millis(100)); + } + Err(e) => return Err(format!("failed to wait for the audio backend to exit: {e}")), + } + } +} + +/// Rename, retrying briefly on Windows sharing violations. +/// +/// Even once the backend process is gone, a virus scanner or the search +/// indexer can hold a transient handle inside a directory that was just +/// written or is about to move. These clear in well under a second; without a +/// retry an unlucky scan turns into a failed update mid-swap. +#[cfg(windows)] +fn rename_with_retry(from: &Path, to: &Path, what: &str) -> Result<(), String> { + const ATTEMPTS: u32 = 10; + let mut last_err = None; + for attempt in 0..ATTEMPTS { + match fs::rename(from, to) { + Ok(()) => return Ok(()), + Err(e) => { + last_err = Some(e); + if attempt + 1 < ATTEMPTS { + thread::sleep(Duration::from_millis(150)); + } + } + } + } + Err(format!( + "failed to {what}: {}", + last_err + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown error".to_string()) + )) +} + +/// Applies a previously downloaded+verified app update in place, then +/// relaunches. This is the one piece of the updater with no existing analog in +/// the runtime-pack machinery above: it replaces the *running* exe, not an idle +/// data directory. +/// +/// Only reachable from an explicit "Restart to update" user action, never a +/// background timer, and the backend is stopped first, so this can never land +/// mid-job. Only `StemDeck.exe` and `backend/` are replaced: `python/`, +/// `portable.txt`, `cpu-only` and `data/` are all left exactly as they are, so +/// an NVIDIA install keeps its CUDA torch and portable/GPU detection and user +/// data all survive untouched. +#[tauri::command] +fn apply_app_update( + state: tauri::State, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + #[cfg(not(windows))] + { + let _ = (state, app_handle); + Err("in-app updates are only available on Windows portable installs".to_string()) + } + #[cfg(windows)] + { + let root = app_root()?; + let data_dir = local_data_dir()?; + let downloads = data_dir.join("downloads"); + let app_archive = downloads.join(UPDATE_APP_ARCHIVE); + if !app_archive.is_file() { + return Err( + "no downloaded app update found -- call download_app_update first".to_string(), + ); + } + + // ── Phase 1: stage and validate, touching nothing live ── + // + // Everything that can fail on its own (extraction, a truncated or + // wrong-shaped archive) happens here, before a single live file moves. + // Once phase 2 starts it is only renames, so a failure cannot leave the + // install straddling two versions -- a new backend/ beside the old exe + // would be a broken app with nothing left running to repair it. + let staging = root.join("_update_app.tmp"); + if staging.exists() { + fs::remove_dir_all(&staging) + .map_err(|e| format!("failed to remove {}: {e}", staging.display()))?; + } + + let staged = (|| -> Result { + extract_zip_archive(&app_archive, &staging)?; + let new_exe = staging.join("StemDeck.exe"); + if !staging.join("backend").join("app").is_dir() || !new_exe.is_file() { + return Err( + "app update archive did not contain StemDeck.exe and backend/app".to_string(), + ); + } + Ok(new_exe) + })(); + + let new_exe = match staged { + Ok(path) => path, + Err(e) => { + let _ = fs::remove_dir_all(&staging); + return Err(e); + } + }; + + // ── Phase 2: swap ── + // + // The backend must be gone first: it runs from backend/, and Windows + // will not rename a directory with live handles inside it. + stop_backend_and_wait(&state, Duration::from_secs(15))?; + + let backend_dir = root.join("backend"); + let backend_old = root.join("backend.old"); + let exe_path = root.join("StemDeck.exe"); + let exe_old = root.join("StemDeck.exe.old"); + if backend_old.exists() { + fs::remove_dir_all(&backend_old) + .map_err(|e| format!("failed to remove {}: {e}", backend_old.display()))?; + } + if exe_old.exists() { + let _ = fs::remove_file(&exe_old); + } + + if backend_dir.exists() { + rename_with_retry( + &backend_dir, + &backend_old, + "move the existing backend aside", + )?; + } + rename_with_retry( + &staging.join("backend"), + &backend_dir, + "install the updated backend", + )?; + + // The exe goes last. Windows allows renaming a running process's own + // on-disk image -- the OS holds the file open by handle, not by path -- + // so this needs no elevated privileges in a user-writable portable + // folder. + // + // Known residual gap: these two renames are back-to-back metadata + // updates on one volume, but they are not a single atomic operation. A + // hard crash in that window would leave StemDeck.exe absent with + // StemDeck.exe.old holding the previous build, recoverable only by a + // manual rename -- unlike the swaps above there is no surviving + // process to self-heal it on next launch. Closing it fully needs a + // separate bootstrap launcher that is never itself replaced; flagging + // it rather than treating it as solved. + rename_with_retry(&exe_path, &exe_old, "move the running app aside")?; + rename_with_retry(&new_exe, &exe_path, "install the updated app")?; + + let _ = fs::remove_dir_all(&staging); + let _ = fs::remove_file(&app_archive); + + // Relaunch the new exe detached, then exit. The old exe, renamed aside + // above, keeps running under its own open handle until this process + // actually exits; the *.old siblings are swept up on the next launch by + // setup()'s post-update cleanup. + Command::new(&exe_path) + .current_dir(&root) + .spawn() + .map_err(|e| format!("failed to relaunch the updated app: {e}"))?; + app_handle.exit(0); + Ok(()) + } +} + /// Creates required data directories and runs any pending data migrations. #[tauri::command] fn ensure_workspace() -> Result<(), String> { @@ -3816,6 +4133,39 @@ mod tests { // clear_webkit_data suppresses NotFound — this is the correct behavior. } + // --- In-app updater runtime-compatibility marker (#421) --- + + #[test] + fn parses_the_runtime_id_make_portable_writes() { + // Byte-for-byte what scripts/windows/make-portable.ps1 emits into + // python/runtime-version.json (ConvertTo-Json -Compress, UTF-8, no BOM, + // trailing newline). If that shape changes, this fails rather than the + // updater silently reading None and sending everyone to the full + // download forever. + let written = "{\"runtimeId\":\"py3.12-dbda45e38e1044cf\"}\n"; + assert_eq!( + super::parse_runtime_id(written).as_deref(), + Some("py3.12-dbda45e38e1044cf") + ); + } + + #[test] + fn unreadable_runtime_markers_are_none_not_a_wrong_match() { + // Every one of these must read as "unknown", which the frontend treats + // as incompatible. Returning a bogus id instead could let an app-only + // update land on a runtime that cannot satisfy its imports. + for text in [ + "", + "not json", + "{}", + "{\"runtimeId\":null}", + "{\"runtimeId\":42}", + "{\"version\":\"0.12.2\"}", + ] { + assert_eq!(super::parse_runtime_id(text), None, "input: {text:?}"); + } + } + // --- macOS FFmpeg checksum verification (#172) --- #[cfg(target_os = "macos")] diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 6aa3587..25d3525 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -5,7 +5,7 @@ param( [string]$PackageVersion, [switch]$SkipTauriBuild, [switch]$CpuOnly, - [switch]$StripVenv + [switch]$PublishUpdaterAssets ) $ErrorActionPreference = "Stop" @@ -229,20 +229,69 @@ if ($CpuOnly) { Bundle-PythonRuntime $PythonDir $PythonExe & $PythonExe -c "import sys, fastapi, uvicorn; print('Portable Python:', sys.executable)" -if ($StripVenv) { - Write-Host "Stripping venv of build-time artifacts..." - Get-ChildItem -Path $PythonDir -Filter "__pycache__" -Recurse -Directory -Force | - Remove-Item -Recurse -Force - foreach ($rel in @("torch\include", "torch\share\cmake", "torch\test")) { - $p = Join-Path $PythonDir "Lib\site-packages\$rel" - if (Test-Path $p) { Remove-Item -Recurse -Force $p } - } - # Remove C++ static link libraries from torch — needed only for building C++ extensions, - # never for running Python. dnnl.lib alone is ~623 MB. - Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages\torch") ` - -Filter "*.lib" -Recurse -File -Force | - Remove-Item -Force +Write-Host "Stripping venv of build-time and dead-weight artifacts..." +Get-ChildItem -Path $PythonDir -Filter "__pycache__" -Recurse -Directory -Force | + Remove-Item -Recurse -Force +foreach ($rel in @("torch\include", "torch\share\cmake", "torch\test")) { + $p = Join-Path $PythonDir "Lib\site-packages\$rel" + if (Test-Path $p) { Remove-Item -Recurse -Force $p } } +# Remove C++ static link libraries from torch — needed only for building C++ extensions, +# never for running Python. dnnl.lib alone is ~623 MB. +Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages\torch") ` + -Filter "*.lib" -Recurse -File -Force | + Remove-Item -Force + +# The stdlib's own test suite (Lib/test) and every package's bundled test/tests +# directory are never imported by the running app -- pure dead weight that is a +# large share of the ~20k loose files in the shipped venv (#421). +$stdlibTest = Join-Path $PythonDir "base\Lib\test" +if (Test-Path $stdlibTest) { Remove-Item -Recurse -Force $stdlibTest } +Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages") -Directory -Force | + ForEach-Object { + foreach ($name in @("test", "tests")) { + $p = Join-Path $_.FullName $name + if (Test-Path $p) { Remove-Item -Recurse -Force $p } + } + } + +# NOTE: do NOT strip .dist-info RECORD files to save space. pip needs RECORD to +# uninstall or replace a package, and the NVIDIA build pip-installs CUDA torch +# into this very venv on the user's first run (install_cuda_torch). Without +# RECORD that turns into "Failed to uninstall ... due to missing RECORD file. +# Installation may result in an incomplete environment" -- a broken torch on the +# machines that most need a working one, to save a few hundred KB. + +# The strip above widened what ships (#421), so re-verify the packaged +# interpreter can still import everything the pipeline needs -- the earlier +# import check ran pre-strip and pre-bundle, and would not catch a strip that +# removed something load-bearing. Same rationale as that check: catch it here +# rather than in a release (#407). +& $PythonExe -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile, audio_separator, onnxruntime; print('Post-strip import check OK')" + +# Runtime fingerprint, shipped INSIDE python/ in every package (#421). +# +# This is the in-app updater's SAFETY GATE, not a download trigger. The updater +# only ever replaces StemDeck.exe + backend/; it never touches python/, because +# an NVIDIA install rewrites python/ with CUDA torch at first run +# (install_cuda_torch) and swapping the directory would silently drop that user +# back to CPU. So an app-only update is safe exactly when the new release needs +# the same Python dependencies the install already has -- and this id is how the +# updater checks that. When it differs, the updater stands down and points the +# user at the full package download instead. +# +# Derived from uv.lock (the dependency set, identical for both variants) plus +# the bundled interpreter's major.minor. Deliberately NOT the package version, +# which changes every release and would make every update look incompatible; +# and deliberately not the installed wheel list, which differs between the CPU +# and NVIDIA variants by torch's local version tag alone (2.6.0+cpu vs 2.6.0) +# even though their dependency requirements are identical. +$PyMajorMinor = (& $PythonExe -c "import sys; print('%d.%d' % sys.version_info[:2])").Trim() +$LockHash = (Get-FileHash -Algorithm SHA256 -Path (Join-Path $Root "uv.lock")).Hash.Substring(0, 16).ToLower() +$RuntimeId = "py$PyMajorMinor-$LockHash" +$RuntimeIdJson = @{ runtimeId = $RuntimeId } | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText((Join-Path $PythonDir "runtime-version.json"), $RuntimeIdJson + "`n", $utf8NoBom) +Write-Host "Runtime id : $RuntimeId" Push-Location $DesktopDir try { @@ -273,8 +322,38 @@ Compress-Archive -Path (Join-Path $Stage "*") -DestinationPath $ZipPath -Force $Hash = Get-FileHash -Algorithm SHA256 $ZipPath Set-Content -Path $ChecksumPath -Value "$($Hash.Hash) $PackageName.zip" +# Slim "app layer" asset for the in-app updater (#421). The full zip above is +# untouched and remains the only thing a fresh install ever needs; this is an +# extra, much smaller asset used only by an already-installed app updating +# itself, so it never has to re-extract the ~20k-file Python runtime to pick up +# a release that only changed app code. +# +# StemDeck.exe and backend/ are byte-identical between the CPU and NVIDIA +# variants -- the only per-variant difference in the package is the `cpu-only` +# marker at the package root, which the updater never touches -- so this is +# built once, from whichever invocation passes -PublishUpdaterAssets, and needs +# no variant suffix. +# +# There is deliberately NO runtime asset: the updater never replaces python/. +# See the runtime-id note above for why. runtime-version.json is published on +# its own so the updater can check compatibility before offering to update. +if ($PublishUpdaterAssets) { + $UpdaterAppZipName = "StemDeck-Windows-x64-app" + $UpdaterAppZipPath = Join-Path $Root "$OutputRoot\$UpdaterAppZipName.zip" + Compress-Archive -Path (Join-Path $Stage "StemDeck.exe"), (Join-Path $Stage "backend") ` + -DestinationPath $UpdaterAppZipPath -Force + $AppHash = Get-FileHash -Algorithm SHA256 $UpdaterAppZipPath + Set-Content -Path "$UpdaterAppZipPath.sha256" -Value "$($AppHash.Hash) $UpdaterAppZipName.zip" + + $RuntimeIdAssetPath = Join-Path $Root "$OutputRoot\StemDeck-Windows-x64-runtime-version.json" + [System.IO.File]::WriteAllText($RuntimeIdAssetPath, $RuntimeIdJson + "`n", $utf8NoBom) +} + $Variant = if ($CpuOnly) { "CPU-only" } else { "CUDA/GPU (NVIDIA)" } Write-Host "Variant : $Variant" Write-Host "Staged at : $Stage" Write-Host "Zip created : $ZipPath" Write-Host "Checksum : $ChecksumPath" +if ($PublishUpdaterAssets) { + Write-Host "Updater app pack : $UpdaterAppZipPath" +} diff --git a/static/css/daw.css b/static/css/daw.css index 9573429..d63b279 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -2786,12 +2786,29 @@ input, textarea { font-family: inherit; } } .release-docker-note { margin: 0; font-size: 11px; color: var(--muted); } +/* Windows desktop: in-app download + apply, in place of a browser download. */ +.release-inapp { width: 100%; text-align: left; margin: 0 0 16px; } +.release-inapp-progress-bar { + height: 3px; border-radius: 2px; margin-bottom: 6px; + background: rgba(255, 255, 255, 0.09); overflow: hidden; +} +.release-inapp-progress-fill { + height: 100%; width: 0; border-radius: 2px; + background: var(--accent); + transition: width 260ms linear; +} +.release-inapp-progress-text { margin: 0; font-size: 11px; color: var(--muted); } +.release-inapp-error { margin: 0; font-size: 12px; color: #e54e4e; } + /* The release dialog lives outside `.daw`, so the scoped `.daw .hidden` rule does not reach its inner elements and base.css's global `.hidden` is not loaded on this page. Hide the mode-specific parts explicitly (same approach as `.about-backdrop.hidden`) so desktop hides the docker block and server hides the download button. */ .release-docker.hidden, +.release-inapp.hidden, +.release-inapp-progress.hidden, +.release-inapp-error.hidden, .release-card .about-link.hidden { display: none !important; } diff --git a/static/index.html b/static/index.html index a405252..9b64411 100644 --- a/static/index.html +++ b/static/index.html @@ -896,8 +896,18 @@

New release available

On Unraid, update via the Community Applications template instead.

+ + diff --git a/static/js/catalog.js b/static/js/catalog.js index 773c9a8..d430eb6 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -2266,6 +2266,148 @@ function pickReleaseAsset(release, target) { return asset ? { url: asset.browser_download_url, name } : null; } +// ─── Windows in-app updater ─── +// Downloads and applies an update without leaving the app, instead of sending +// the user to a browser download -- see download_app_update/apply_app_update in +// desktop/src-tauri/src/main.rs for the file swap. Windows-only: the portable +// zip is the only distribution shaped for an in-place swap. +// +// The updater replaces StemDeck.exe and backend/ ONLY. It never touches +// python/, because an NVIDIA install rewrites that directory with CUDA torch on +// first run and replacing it would silently drop the machine back to CPU. So an +// in-app update is only safe when the release needs the same Python +// dependencies the install already has -- that is what the runtime id gates. +// When it doesn't match, we fall back to the normal full-package download. + +function findReleaseAsset(release, name) { + return (release.assets || []).find((a) => a.name === name) || null; +} + +async function fetchAssetText(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + return res.text(); +} + +// The checksum files this release publishes are ` ` lines +// (make-portable.ps1's `Get-FileHash` + `Set-Content` convention) -- same +// shape BtbN's FFmpeg checksums.sha256 uses, just one line here. +function parseSha256File(text) { + const hash = (text.trim().split(/\s+/)[0] || "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("malformed checksum file"); + return hash; +} + +// Resolves the app-layer asset for the in-app updater, or null when this +// release cannot be applied in place -- it predates the updater assets, is +// missing one, or changed the Python dependency set. Null means "use the full +// download link", which is always correct, just less convenient. +async function resolveWindowsUpdatePlan(release) { + const appAsset = findReleaseAsset(release, "StemDeck-Windows-x64-app.zip"); + const appShaAsset = findReleaseAsset(release, "StemDeck-Windows-x64-app.zip.sha256"); + const runtimeIdAsset = findReleaseAsset(release, "StemDeck-Windows-x64-runtime-version.json"); + if (!appAsset || !appShaAsset || !runtimeIdAsset) return null; + + // Compatibility gate. Any uncertainty here -- an unreadable asset, an install + // with no recorded runtime id -- must fall back to the full download: an app + // layer whose imports the installed runtime cannot satisfy is a broken app. + const releaseRuntimeId = JSON.parse(await fetchAssetText(runtimeIdAsset.browser_download_url))?.runtimeId; + const installedRuntimeId = await window.__TAURI__.core.invoke("installed_runtime_id"); + if (!releaseRuntimeId || !installedRuntimeId || releaseRuntimeId !== installedRuntimeId) { + return null; + } + + return { + appUrl: appAsset.browser_download_url, + appSha256: parseSha256File(await fetchAssetText(appShaAsset.browser_download_url)), + }; +} + +let _appUpdateProgressUnlisten = null; + +function showInappError(message) { + const errorEl = document.getElementById("releaseInappError"); + if (!errorEl) return; + errorEl.textContent = `${i18nT("release.updateFailed")}: ${message}`; + errorEl.classList.remove("hidden"); +} + +// Wires the download/apply buttons for a resolved plan. Returns false (and +// touches nothing) when this release has no in-app-updatable assets, so the +// caller can fall back to the plain download link. +async function wireWindowsUpdate() { + const downloadBtn = document.getElementById("releaseDownloadApp"); + const applyBtn = document.getElementById("releaseApplyUpdate"); + const inapp = document.getElementById("releaseInapp"); + const progress = document.getElementById("releaseInappProgress"); + const progressFill = document.getElementById("releaseInappProgressFill"); + const progressText = document.getElementById("releaseInappProgressText"); + const errorEl = document.getElementById("releaseInappError"); + if (!downloadBtn || !applyBtn || !latestRelease) return false; + + const plan = await resolveWindowsUpdatePlan(latestRelease); + if (!plan) return false; + + document.getElementById("releaseDownload")?.classList.add("hidden"); + inapp?.classList.remove("hidden"); + progress?.classList.add("hidden"); + errorEl?.classList.add("hidden"); + downloadBtn.disabled = false; + applyBtn.disabled = false; + downloadBtn.classList.remove("hidden"); + applyBtn.classList.add("hidden"); + + downloadBtn.onclick = async () => { + errorEl?.classList.add("hidden"); + downloadBtn.disabled = true; + if (progressFill) progressFill.style.width = "0%"; + if (progressText) progressText.textContent = i18nT("release.downloading"); + progress?.classList.remove("hidden"); + + if (_appUpdateProgressUnlisten) { _appUpdateProgressUnlisten(); _appUpdateProgressUnlisten = null; } + try { + _appUpdateProgressUnlisten = await window.__TAURI__.event.listen( + "runtime-download-progress", + (event) => { + const { received, total } = event.payload; + if (total && progressFill) { + progressFill.style.width = `${Math.min(100, Math.round((received / total) * 100))}%`; + } + }, + ); + await window.__TAURI__.core.invoke("download_app_update", { plan }); + progress?.classList.add("hidden"); + downloadBtn.classList.add("hidden"); + applyBtn.classList.remove("hidden"); + } catch (e) { + console.warn("[catalog] download_app_update failed:", e); + showInappError(String(e?.message || e)); + downloadBtn.disabled = false; + progress?.classList.add("hidden"); + } finally { + if (_appUpdateProgressUnlisten) { _appUpdateProgressUnlisten(); _appUpdateProgressUnlisten = null; } + } + }; + + applyBtn.onclick = async () => { + errorEl?.classList.add("hidden"); + applyBtn.disabled = true; + if (progressText) progressText.textContent = i18nT("release.applying"); + progress?.classList.remove("hidden"); + try { + // On success the app exits and relaunches -- this promise never resolves. + await window.__TAURI__.core.invoke("apply_app_update"); + } catch (e) { + console.warn("[catalog] apply_app_update failed:", e); + showInappError(String(e?.message || e)); + applyBtn.disabled = false; + progress?.classList.add("hidden"); + } + }; + + return true; +} + async function openReleaseDialog() { const dialog = document.getElementById("releaseDialog"); if (!dialog || !latestRelease) return; @@ -2295,17 +2437,32 @@ async function openReleaseDialog() { } else if (download) { docker?.classList.add("hidden"); const target = await getBuildTarget(); - const picked = pickReleaseAsset(latestRelease, target); - if (picked) { - download.href = picked.url; - download.textContent = i18nT("release.download"); - } else { - // No matching asset (e.g. an arch we don't build): fall back to the - // release page so the user can pick manually. - download.href = latestRelease.html_url || RELEASES_URL; - download.textContent = i18nT("release.viewDownload"); + + let usedInapp = false; + if (target.os === "windows") { + try { + usedInapp = await wireWindowsUpdate(); + } catch (e) { + console.warn("[catalog] in-app update setup failed, falling back to link:", e); + } + } + + if (!usedInapp) { + document.getElementById("releaseInapp")?.classList.add("hidden"); + document.getElementById("releaseDownloadApp")?.classList.add("hidden"); + document.getElementById("releaseApplyUpdate")?.classList.add("hidden"); + const picked = pickReleaseAsset(latestRelease, target); + if (picked) { + download.href = picked.url; + download.textContent = i18nT("release.download"); + } else { + // No matching asset (e.g. an arch we don't build): fall back to the + // release page so the user can pick manually. + download.href = latestRelease.html_url || RELEASES_URL; + download.textContent = i18nT("release.viewDownload"); + } + download.classList.remove("hidden"); } - download.classList.remove("hidden"); } dialog.classList.remove("hidden"); diff --git a/static/js/i18n.js b/static/js/i18n.js index 52be409..9246c2e 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -386,6 +386,11 @@ const en = { "release.unraidNote": "On Unraid, update via the Community Applications template instead.", "release.download": "Download", "release.allReleases": "All releases", + "release.downloadUpdate": "Download update", + "release.downloading": "Downloading update…", + "release.restartUpdate": "Restart to update", + "release.applying": "Applying update…", + "release.updateFailed": "Update failed", "failure.title": "Something failed", "failure.closeAria": "Close failure dialog", @@ -885,6 +890,11 @@ const pl = { "release.unraidNote": "Na Unraid zaktualizuj przez szablon Community Applications.", "release.download": "Pobierz", "release.allReleases": "Wszystkie wersje", + "release.downloadUpdate": "Pobierz aktualizację", + "release.downloading": "Pobieranie aktualizacji…", + "release.restartUpdate": "Uruchom ponownie, aby zaktualizować", + "release.applying": "Instalowanie aktualizacji…", + "release.updateFailed": "Aktualizacja nie powiodła się", "failure.title": "Coś poszło nie tak", "failure.closeAria": "Zamknij okno błędu", @@ -1375,6 +1385,11 @@ const ja = { "release.unraidNote": "Unraidの場合は、Community Applicationsテンプレート経由で更新してください。", "release.download": "ダウンロード", "release.allReleases": "すべてのリリース", + "release.downloadUpdate": "アップデートをダウンロード", + "release.downloading": "アップデートをダウンロード中…", + "release.restartUpdate": "再起動して更新", + "release.applying": "アップデートを適用中…", + "release.updateFailed": "アップデートに失敗しました", "failure.title": "エラーが発生しました", "failure.closeAria": "エラーダイアログを閉じる", @@ -1841,6 +1856,11 @@ const zhHans = { "release.unraidNote": "在 Unraid 上,请通过 Community Applications 模板更新。", "release.download": "下载", "release.allReleases": "所有版本", + "release.downloadUpdate": "下载更新", + "release.downloading": "正在下载更新…", + "release.restartUpdate": "重启以更新", + "release.applying": "正在应用更新…", + "release.updateFailed": "更新失败", "failure.title": "出现故障", "failure.closeAria": "关闭故障对话框", @@ -2308,6 +2328,11 @@ const de = { "release.unraidNote": "Bei Unraid stattdessen über die Community-Applications-Vorlage aktualisieren.", "release.download": "Herunterladen", "release.allReleases": "Alle Versionen", + "release.downloadUpdate": "Update herunterladen", + "release.downloading": "Update wird heruntergeladen…", + "release.restartUpdate": "Zum Aktualisieren neu starten", + "release.applying": "Update wird angewendet…", + "release.updateFailed": "Update fehlgeschlagen", "failure.title": "Etwas ist fehlgeschlagen", "failure.closeAria": "Fehlerdialog schließen", @@ -2785,6 +2810,11 @@ const pt = { "release.unraidNote": "No Unraid, atualize pelo modelo do Community Applications.", "release.download": "Baixar", "release.allReleases": "Todas as versões", + "release.downloadUpdate": "Baixar atualização", + "release.downloading": "Baixando atualização…", + "release.restartUpdate": "Reiniciar para atualizar", + "release.applying": "Aplicando atualização…", + "release.updateFailed": "Falha na atualização", "failure.title": "Algo falhou", "failure.closeAria": "Fechar diálogo de falha", @@ -3263,6 +3293,11 @@ const id = { "release.unraidNote": "Di Unraid, perbarui melalui template Community Applications.", "release.download": "Unduh", "release.allReleases": "Semua versi", + "release.downloadUpdate": "Unduh pembaruan", + "release.downloading": "Mengunduh pembaruan…", + "release.restartUpdate": "Mulai ulang untuk memperbarui", + "release.applying": "Menerapkan pembaruan…", + "release.updateFailed": "Pembaruan gagal", "failure.title": "Terjadi kesalahan", "failure.closeAria": "Tutup dialog kesalahan", From 8c0a96689f039e97185feaab2d3bf366a4943062 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 10:07:33 +0100 Subject: [PATCH 2/8] fix(updater): make the in-app update actually work, verified end to end (#421) Built both packages on a real Windows box and drove the whole flow. Four bugs that only surfaced by running it, none of which static checks could see. 1. Stale version after updating. app_version() read installed dist metadata, which lives in python/ -- the directory the updater deliberately never replaces. A self-updated install kept reporting the old version and would re-offer an update it had already applied, forever. It now prefers the app layer's static/version.json, which moves with backend/. Gitignored, so Docker and source checkouts still fall through to the hatch-vcs metadata. Proven: after a real update, python/ dist-info says 0.13.0 while /api/health reports 0.13.1. 2. The page CSP blocked the whole feature. The UI is served over http by the Python backend, so its connect-src applies: api.github.com is allowed, github.com and objects.githubusercontent.com are not, and that is where release assets live. Fetching the checksum and runtime id from JS was refused, so the pill would simply never appear. Those two reads moved into Rust (check_app_update), whose HTTP client is not bound by the page CSP, so the policy from #171 stays exactly as tight as it was. 3. plugin:event|listen refused by the Tauri ACL. App-defined commands are not ACL-gated but plugin commands are, and the capability does not cover the remote http origin the UI is served from. The progress bar is now indeterminate instead of granting a remote origin event permissions to put a percentage on a 5 MB download. 4. The post-strip import check re-bloated the package. Running Python regenerated 1,912 files / 39 MB of __pycache__ that the strip had just removed, cancelling nearly all of it: the net saving was 180 files. Swept once after the last interpreter run, and backend/ no longer ships a developer's local __pycache__ either. Also: the *.old sweep now runs on every launch rather than only on a version change. apply_app_update relaunches then exits, so on the first launch of the new build Windows still holds StemDeck.exe.old open, the delete fails silently, and gated on a change that already happened it would never retry. Observed for real: 15.7 MB stranded. Verified swept on the next launch. UI: "Update now" is an accent pill BESIDE Download, not a replacement, so the zip stays one click away and is the escape hatch if an update fails. Measured against the published v0.13.0 package: 18,143 -> 16,056 files (-2,087, -11.5%) and 883 -> 850 MB. The real win for #421 is the update path itself: 5 MB and 123 files instead of 284 MB and 16,056. Verified on this machine: a real 6-stem Demucs separation through the stripped package; the full notify -> Update now -> download -> restart -> relaunch cycle, after which user data (job, 7 stems, 130 MB of models), portable.txt, cpu-only and python/ were all untouched; and the safety gate correctly declining, with no download attempted, when the release's runtime id differs. Not covered: the NVIDIA package was not built, though the risk that motivated the gate is structurally gone now that python/ is never swapped. --- app/main.py | 24 +++- desktop/src-tauri/src/main.rs | 212 +++++++++++++++++++++++++++--- scripts/windows/make-portable.ps1 | 14 ++ static/css/daw.css | 19 +++ static/index.html | 4 +- static/js/catalog.js | 98 ++++++-------- static/js/i18n.js | 14 +- tests/test_health_api.py | 40 ++++++ 8 files changed, 339 insertions(+), 86 deletions(-) diff --git a/app/main.py b/app/main.py index a584472..3e0a56d 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ import asyncio import functools import io +import json import logging import os import re @@ -94,9 +95,26 @@ def app_version() -> str: - # Version is git-tag-derived via hatch-vcs (#169). Prefer installed package - # metadata (set at install/build from the tag); fall back to the generated - # app/_version.py for non-installed runs, then a dev placeholder. + # Version is git-tag-derived via hatch-vcs (#169). + # + # A packaged desktop install carries its version in the app layer + # (static/version.json, written by the make-portable scripts), and that is + # checked FIRST. The in-app updater (#421) replaces backend/ but + # deliberately never replaces python/, where the installed dist metadata + # lives -- so after a self-update that metadata is a version behind, and + # trusting it would make the app keep offering an update it already applied. + # The file is gitignored, so Docker images and source checkouts do not have + # it and correctly fall through to the metadata below. + try: + # utf-8-sig: some writers emit a BOM, which json.loads rejects. + raw = (STATIC_DIR / "version.json").read_text(encoding="utf-8-sig") + packaged = json.loads(raw).get("version") + if isinstance(packaged, str) and packaged.strip(): + return packaged.strip() + except Exception: + pass + # Installed package metadata (set at install/build from the tag); then the + # generated app/_version.py for non-installed runs, then a dev placeholder. try: return package_version("stemdeck") except PackageNotFoundError: diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 666cd28..baf158f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -217,6 +217,36 @@ struct AppUpdatePlan { app_sha256: String, } +/// Asset URLs lifted from the GitHub release JSON by the frontend, for +/// `check_app_update` to resolve. +/// +/// The small metadata files are fetched HERE rather than in JS on purpose. The +/// page is served by the Python backend over http, so the backend's own +/// Content-Security-Policy applies to it, and `connect-src` allows +/// `api.github.com` but NOT `github.com`/`objects.githubusercontent.com` where +/// release *assets* actually live (app/main.py). A `fetch()` for the checksum +/// or the runtime id would be blocked outright and the updater would silently +/// never appear. reqwest is not bound by the page CSP, so doing it in Rust +/// keeps that policy exactly as tight as it is today. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(not(windows), allow(dead_code))] +struct AppUpdateQuery { + app_sha_url: String, + runtime_id_url: String, +} + +/// Whether this release can be installed in place, and the verified checksum to +/// install it with. `reason` is for the log, not the user: the UI just falls +/// back to the normal download link. +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +struct AppUpdateAvailability { + supported: bool, + app_sha256: Option, + reason: Option, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct DownloadProgress { @@ -276,6 +306,21 @@ fn main() { }; let _ = fs::create_dir_all(&data_dir); + // Sweep what an in-app update left at the app root: the previous + // backend/ and exe, plus a staging dir if the update was + // interrupted before it could clean up. The new files are already + // in place, so these are only ever the old version's leftovers. + // + // Runs on EVERY launch, not just a version change. apply_app_update + // relaunches and then exits, so on the very first launch of the new + // build the outgoing process is usually still alive and Windows + // still holds StemDeck.exe.old open -- the delete fails silently + // and, gated on a version change that has already happened, would + // never be retried. Verified: after a real self-update both + // backend.old and StemDeck.exe.old were still on disk. Three path + // checks per launch is nothing; leaking ~30 MB forever is not. + sweep_update_leftovers(); + let version_file = data_dir.join("last_version.txt"); let migration_flag = data_dir.join("store_migration_done"); let current = env!("CARGO_PKG_VERSION"); @@ -297,23 +342,6 @@ fn main() { // moved their library to another disk back to the default // folder, to an empty app with their stems stranded. prune_runtime_leftovers(&data_dir); - // Sweep what an in-app update left at the app root: the - // previous backend/ and exe, plus a staging directory if the - // update was interrupted before it could clean up. The new - // files are already in place either way, so these are only - // ever the old version's leftovers (#421). - if let Ok(root) = app_root() { - for name in ["backend.old", "_update_app.tmp"] { - let stale = root.join(name); - if stale.is_dir() { - let _ = fs::remove_dir_all(&stale); - } - } - let stale_exe = root.join("StemDeck.exe.old"); - if stale_exe.is_file() { - let _ = fs::remove_file(&stale_exe); - } - } let manifest = app_root() .ok() .and_then(|root| load_runtime_manifest(&root).ok()); @@ -354,6 +382,7 @@ fn main() { verify_runtime_pack, extract_runtime_pack, installed_runtime_id, + check_app_update, download_app_update, apply_app_update, ensure_external_assets, @@ -445,7 +474,8 @@ fn current_jobs_dir(app: &tauri::AppHandle) -> PathBuf { /// delete so a problem here can never lose the only copy of that data. fn documents_store_path(app: &tauri::AppHandle) -> Result { let jobs_dir = current_jobs_dir(app); - fs::create_dir_all(&jobs_dir).map_err(|e| format!("failed to create {}: {e}", jobs_dir.display()))?; + fs::create_dir_all(&jobs_dir) + .map_err(|e| format!("failed to create {}: {e}", jobs_dir.display()))?; let new_path = jobs_dir.join("user-data.json"); if !new_path.is_file() { if let Ok(old_path) = documents_stemdeck_dir(app).map(|d| d.join("user-data.json")) { @@ -781,6 +811,23 @@ fn extract_runtime_pack() -> Result { runtime_pack_status() } +/// Delete the `.old` siblings and staging dir an in-app update leaves at the +/// app root (#421). Best-effort and idempotent: whatever is still locked by the +/// outgoing process this launch is simply picked up on the next one. +fn sweep_update_leftovers() { + let Ok(root) = app_root() else { return }; + for name in ["backend.old", "python.old", "_update_app.tmp"] { + let stale = root.join(name); + if stale.is_dir() { + let _ = fs::remove_dir_all(&stale); + } + } + let stale_exe = root.join("StemDeck.exe.old"); + if stale_exe.is_file() { + let _ = fs::remove_file(&stale_exe); + } +} + /// The Python dependency-set id of the runtime currently on disk, written into /// `python/runtime-version.json` by make-portable.ps1. `None` when the marker /// is absent -- a pre-#421 install, a non-Windows build, or a source checkout. @@ -804,6 +851,107 @@ fn parse_runtime_id(text: &str) -> Option { value.get("runtimeId")?.as_str().map(|s| s.to_string()) } +/// Decides whether the latest release can be applied in place, and resolves its +/// checksum. Windows-only; every other platform reports unsupported. +/// +/// An in-app update is offered only when the release's Python dependency set +/// matches the installed one, because the updater cannot replace `python/` +/// (see `AppUpdatePlan`). Any uncertainty -- an unreachable asset, an install +/// with no recorded runtime id, a malformed checksum -- reports unsupported, so +/// the UI falls back to the full download rather than risking an app layer +/// whose imports the installed runtime cannot satisfy. +#[tauri::command] +async fn check_app_update(query: AppUpdateQuery) -> Result { + #[cfg(not(windows))] + { + let _ = query; + Ok(AppUpdateAvailability { + supported: false, + reason: Some("in-app updates are Windows-only".to_string()), + ..Default::default() + }) + } + #[cfg(windows)] + { + let unsupported = |reason: &str| { + Ok(AppUpdateAvailability { + supported: false, + reason: Some(reason.to_string()), + ..Default::default() + }) + }; + + let Some(installed) = installed_runtime_id() else { + return unsupported("this install records no runtime id"); + }; + let release_marker = match fetch_text(&query.runtime_id_url).await { + Ok(text) => text, + Err(e) => return unsupported(&format!("could not read the release runtime id: {e}")), + }; + let Some(release_id) = parse_runtime_id(&release_marker) else { + return unsupported("the release runtime id could not be parsed"); + }; + if release_id != installed { + return unsupported(&format!( + "python dependencies changed ({installed} -> {release_id})" + )); + } + + let checksum_file = match fetch_text(&query.app_sha_url).await { + Ok(text) => text, + Err(e) => return unsupported(&format!("could not read the update checksum: {e}")), + }; + let Some(sha256) = parse_sha256_line(&checksum_file) else { + return unsupported("the update checksum could not be parsed"); + }; + + Ok(AppUpdateAvailability { + supported: true, + app_sha256: Some(sha256), + reason: None, + }) + } +} + +/// Fetch a small text file (a checksum, a version marker). Capped so a wrong +/// URL that points at something huge cannot be read into memory unbounded. +#[cfg(windows)] +async fn fetch_text(url: &str) -> Result { + const MAX_BYTES: usize = 64 * 1024; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(60)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let response = client + .get(url) + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + let bytes = response + .bytes() + .await + .map_err(|e| format!("read failed: {e}"))?; + if bytes.len() > MAX_BYTES { + return Err(format!("response larger than {MAX_BYTES} bytes")); + } + String::from_utf8(bytes.to_vec()).map_err(|e| format!("response was not valid UTF-8: {e}")) +} + +/// Pull the hash out of a ` ` checksum file, the shape +/// make-portable.ps1 writes (Get-FileHash + Set-Content). Rejects anything that +/// is not exactly one 64-char hex digest so a redirect to an HTML error page +/// can never be mistaken for a checksum. +#[cfg(any(windows, test))] +fn parse_sha256_line(text: &str) -> Option { + let token = text.split_whitespace().next()?.to_ascii_lowercase(); + let ok = token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()); + ok.then_some(token) +} + /// Downloads and checksum-verifies the app-layer update. Windows-only: the /// portable zip is the only distribution shaped for an in-place file swap. #[tauri::command] @@ -4166,6 +4314,30 @@ mod tests { } } + #[test] + fn parses_the_checksum_file_make_portable_writes() { + // " " -- Get-FileHash + Set-Content. + let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + let written = format!("{} StemDeck-Windows-x64-app.zip\n", sha.to_uppercase()); + assert_eq!(super::parse_sha256_line(&written).as_deref(), Some(sha)); + } + + #[test] + fn a_non_checksum_response_is_rejected() { + // An asset URL that redirects to an HTML error page must never be + // mistaken for a checksum -- that would verify the download against + // garbage instead of failing closed. + for text in [ + "", + "404", + "not-a-hash file.zip", + "2cf24dba file.zip", + "zzf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 f.zip", + ] { + assert_eq!(super::parse_sha256_line(text), None, "input: {text:?}"); + } + } + // --- macOS FFmpeg checksum verification (#172) --- #[cfg(target_os = "macos")] @@ -4406,7 +4578,9 @@ b6052160df96b31c9b1e33854a4dcda3d4b57641b880270f31736fb9f445d384 ffmpeg-n7.1-la #[test] fn directory_has_entries_false_for_a_missing_dir() { let dir = make_tmp(); - assert!(!super::directory_has_entries(&dir.path().join("does-not-exist"))); + assert!(!super::directory_has_entries( + &dir.path().join("does-not-exist") + )); } #[test] diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 25d3525..c5f121d 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -181,6 +181,12 @@ if ($CpuOnly) { Copy-Tree (Join-Path $Root "app") (Join-Path $BackendDir "app") Copy-Tree (Join-Path $Root "static") (Join-Path $BackendDir "static") +# Copy-Tree mirrors the working tree, so a developer's local __pycache__ rides +# along into the shipped app layer. Harmless (Python revalidates by mtime+size) +# but it is stale bytecode from someone else's machine, and it is dead weight in +# the small update asset that exists precisely to stay small (#421). +Get-ChildItem -Path $BackendDir -Filter "__pycache__" -Recurse -Directory -Force | + Remove-Item -Recurse -Force $PackageVersion = Get-PackageVersion $VersionJson = @{ version = $PackageVersion } | ConvertTo-Json -Compress $utf8NoBom = New-Object System.Text.UTF8Encoding $false @@ -293,6 +299,14 @@ $RuntimeIdJson = @{ runtimeId = $RuntimeId } | ConvertTo-Json -Compress [System.IO.File]::WriteAllText((Join-Path $PythonDir "runtime-version.json"), $RuntimeIdJson + "`n", $utf8NoBom) Write-Host "Runtime id : $RuntimeId" +# Every Python invocation above wrote __pycache__ back for the modules it +# touched: the post-strip import check alone regenerated a measured 1,912 files +# / 39 MB, which had cancelled out nearly the whole strip. Drop it once here, +# AFTER the last time this script runs the packaged interpreter, so the checks +# stay checks instead of becoming the thing that re-bloats the package. +Get-ChildItem -Path $PythonDir -Filter "__pycache__" -Recurse -Directory -Force | + Remove-Item -Recurse -Force + Push-Location $DesktopDir try { if (Test-Path "package-lock.json") { diff --git a/static/css/daw.css b/static/css/daw.css index d63b279..5499fc7 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -2676,6 +2676,15 @@ input, textarea { font-family: inherit; } color: var(--fg-2); } .about-link-secondary:hover { background: var(--accent); border-color: var(--accent); color: #000; } +/* The in-app update pill: sits beside the plain Download link and is the + recommended action, so it carries the accent fill by default rather than + only on hover. */ +.about-link-accent { + background: var(--accent); border: 1px solid var(--accent); + color: #000; font-weight: 600; cursor: pointer; +} +.about-link-accent:hover { filter: brightness(1.08); } +.about-link-accent:disabled { opacity: 0.55; cursor: default; filter: none; } .about-divider { width: 100%; height: 1px; background: var(--border); margin-bottom: 16px; } @@ -2797,6 +2806,16 @@ input, textarea { font-family: inherit; } background: var(--accent); transition: width 260ms linear; } +/* Indeterminate: the updater cannot listen to the Rust progress event from this + origin (see catalog.js), so the bar shows activity rather than a percentage. */ +.release-inapp-progress.indeterminate .release-inapp-progress-fill { + width: 40%; + animation: release-inapp-slide 1.1s ease-in-out infinite; +} +@keyframes release-inapp-slide { + 0% { margin-left: -40%; } + 100% { margin-left: 100%; } +} .release-inapp-progress-text { margin: 0; font-size: 11px; color: var(--muted); } .release-inapp-error { margin: 0; font-size: 12px; color: #e54e4e; } diff --git a/static/index.html b/static/index.html index 9b64411..edc8832 100644 --- a/static/index.html +++ b/static/index.html @@ -906,8 +906,8 @@

New release available

diff --git a/static/js/catalog.js b/static/js/catalog.js index d430eb6..ee06691 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -2283,48 +2283,37 @@ function findReleaseAsset(release, name) { return (release.assets || []).find((a) => a.name === name) || null; } -async function fetchAssetText(url) { - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); - return res.text(); -} - -// The checksum files this release publishes are ` ` lines -// (make-portable.ps1's `Get-FileHash` + `Set-Content` convention) -- same -// shape BtbN's FFmpeg checksums.sha256 uses, just one line here. -function parseSha256File(text) { - const hash = (text.trim().split(/\s+/)[0] || "").toLowerCase(); - if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("malformed checksum file"); - return hash; -} - // Resolves the app-layer asset for the in-app updater, or null when this // release cannot be applied in place -- it predates the updater assets, is // missing one, or changed the Python dependency set. Null means "use the full // download link", which is always correct, just less convenient. +// +// The checksum and runtime-id files are read by Rust (`check_app_update`), not +// fetched here. This page is served by the Python backend, so its CSP applies, +// and connect-src allows api.github.com but NOT the github.com / +// objects.githubusercontent.com hosts that serve release *assets*. Fetching +// them from JS is blocked outright; Rust's HTTP client is not bound by the page +// CSP, so the policy stays as tight as it is today. async function resolveWindowsUpdatePlan(release) { const appAsset = findReleaseAsset(release, "StemDeck-Windows-x64-app.zip"); const appShaAsset = findReleaseAsset(release, "StemDeck-Windows-x64-app.zip.sha256"); const runtimeIdAsset = findReleaseAsset(release, "StemDeck-Windows-x64-runtime-version.json"); if (!appAsset || !appShaAsset || !runtimeIdAsset) return null; - // Compatibility gate. Any uncertainty here -- an unreadable asset, an install - // with no recorded runtime id -- must fall back to the full download: an app - // layer whose imports the installed runtime cannot satisfy is a broken app. - const releaseRuntimeId = JSON.parse(await fetchAssetText(runtimeIdAsset.browser_download_url))?.runtimeId; - const installedRuntimeId = await window.__TAURI__.core.invoke("installed_runtime_id"); - if (!releaseRuntimeId || !installedRuntimeId || releaseRuntimeId !== installedRuntimeId) { + const check = await window.__TAURI__.core.invoke("check_app_update", { + query: { + appShaUrl: appShaAsset.browser_download_url, + runtimeIdUrl: runtimeIdAsset.browser_download_url, + }, + }); + if (!check?.supported) { + console.info("[catalog] in-app update unavailable:", check?.reason || "unknown"); return null; } - return { - appUrl: appAsset.browser_download_url, - appSha256: parseSha256File(await fetchAssetText(appShaAsset.browser_download_url)), - }; + return { appUrl: appAsset.browser_download_url, appSha256: check.appSha256 }; } -let _appUpdateProgressUnlisten = null; - function showInappError(message) { const errorEl = document.getElementById("releaseInappError"); if (!errorEl) return; @@ -2340,7 +2329,6 @@ async function wireWindowsUpdate() { const applyBtn = document.getElementById("releaseApplyUpdate"); const inapp = document.getElementById("releaseInapp"); const progress = document.getElementById("releaseInappProgress"); - const progressFill = document.getElementById("releaseInappProgressFill"); const progressText = document.getElementById("releaseInappProgressText"); const errorEl = document.getElementById("releaseInappError"); if (!downloadBtn || !applyBtn || !latestRelease) return false; @@ -2348,7 +2336,9 @@ async function wireWindowsUpdate() { const plan = await resolveWindowsUpdatePlan(latestRelease); if (!plan) return false; - document.getElementById("releaseDownload")?.classList.add("hidden"); + // The manual download stays visible alongside the auto-update pill: some + // people would rather grab the zip, and it is the escape hatch if an in-app + // update fails. inapp?.classList.remove("hidden"); progress?.classList.add("hidden"); errorEl?.classList.add("hidden"); @@ -2357,24 +2347,21 @@ async function wireWindowsUpdate() { downloadBtn.classList.remove("hidden"); applyBtn.classList.add("hidden"); + // Indeterminate, not a byte-accurate bar. Real progress would mean listening + // to the Rust download event, and this page is served over http by the Python + // backend -- a remote origin, which the Tauri capability in + // desktop/src-tauri/capabilities/default.json does not cover, so + // `plugin:event|listen` is refused by the ACL. Granting a remote origin event + // permissions would widen exactly the IPC surface #171 locked down, and the + // app layer is ~5 MB. Not worth it. (App-defined commands like the invokes + // below are not ACL-gated, which is why those work.) downloadBtn.onclick = async () => { errorEl?.classList.add("hidden"); downloadBtn.disabled = true; - if (progressFill) progressFill.style.width = "0%"; if (progressText) progressText.textContent = i18nT("release.downloading"); progress?.classList.remove("hidden"); - - if (_appUpdateProgressUnlisten) { _appUpdateProgressUnlisten(); _appUpdateProgressUnlisten = null; } + progress?.classList.add("indeterminate"); try { - _appUpdateProgressUnlisten = await window.__TAURI__.event.listen( - "runtime-download-progress", - (event) => { - const { received, total } = event.payload; - if (total && progressFill) { - progressFill.style.width = `${Math.min(100, Math.round((received / total) * 100))}%`; - } - }, - ); await window.__TAURI__.core.invoke("download_app_update", { plan }); progress?.classList.add("hidden"); downloadBtn.classList.add("hidden"); @@ -2384,8 +2371,6 @@ async function wireWindowsUpdate() { showInappError(String(e?.message || e)); downloadBtn.disabled = false; progress?.classList.add("hidden"); - } finally { - if (_appUpdateProgressUnlisten) { _appUpdateProgressUnlisten(); _appUpdateProgressUnlisten = null; } } }; @@ -2438,6 +2423,21 @@ async function openReleaseDialog() { docker?.classList.add("hidden"); const target = await getBuildTarget(); + // The manual download is always offered. On Windows, when the release can + // be applied in place, an "Update now" pill appears beside it -- additive, + // never a replacement, so the zip stays one click away either way. + const picked = pickReleaseAsset(latestRelease, target); + if (picked) { + download.href = picked.url; + download.textContent = i18nT("release.download"); + } else { + // No matching asset (e.g. an arch we don't build): fall back to the + // release page so the user can pick manually. + download.href = latestRelease.html_url || RELEASES_URL; + download.textContent = i18nT("release.viewDownload"); + } + download.classList.remove("hidden"); + let usedInapp = false; if (target.os === "windows") { try { @@ -2446,22 +2446,10 @@ async function openReleaseDialog() { console.warn("[catalog] in-app update setup failed, falling back to link:", e); } } - if (!usedInapp) { document.getElementById("releaseInapp")?.classList.add("hidden"); document.getElementById("releaseDownloadApp")?.classList.add("hidden"); document.getElementById("releaseApplyUpdate")?.classList.add("hidden"); - const picked = pickReleaseAsset(latestRelease, target); - if (picked) { - download.href = picked.url; - download.textContent = i18nT("release.download"); - } else { - // No matching asset (e.g. an arch we don't build): fall back to the - // release page so the user can pick manually. - download.href = latestRelease.html_url || RELEASES_URL; - download.textContent = i18nT("release.viewDownload"); - } - download.classList.remove("hidden"); } } diff --git a/static/js/i18n.js b/static/js/i18n.js index 9246c2e..3d77b88 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -386,7 +386,7 @@ const en = { "release.unraidNote": "On Unraid, update via the Community Applications template instead.", "release.download": "Download", "release.allReleases": "All releases", - "release.downloadUpdate": "Download update", + "release.updateNow": "Update now", "release.downloading": "Downloading update…", "release.restartUpdate": "Restart to update", "release.applying": "Applying update…", @@ -890,7 +890,7 @@ const pl = { "release.unraidNote": "Na Unraid zaktualizuj przez szablon Community Applications.", "release.download": "Pobierz", "release.allReleases": "Wszystkie wersje", - "release.downloadUpdate": "Pobierz aktualizację", + "release.updateNow": "Zaktualizuj teraz", "release.downloading": "Pobieranie aktualizacji…", "release.restartUpdate": "Uruchom ponownie, aby zaktualizować", "release.applying": "Instalowanie aktualizacji…", @@ -1385,7 +1385,7 @@ const ja = { "release.unraidNote": "Unraidの場合は、Community Applicationsテンプレート経由で更新してください。", "release.download": "ダウンロード", "release.allReleases": "すべてのリリース", - "release.downloadUpdate": "アップデートをダウンロード", + "release.updateNow": "今すぐ更新", "release.downloading": "アップデートをダウンロード中…", "release.restartUpdate": "再起動して更新", "release.applying": "アップデートを適用中…", @@ -1856,7 +1856,7 @@ const zhHans = { "release.unraidNote": "在 Unraid 上,请通过 Community Applications 模板更新。", "release.download": "下载", "release.allReleases": "所有版本", - "release.downloadUpdate": "下载更新", + "release.updateNow": "立即更新", "release.downloading": "正在下载更新…", "release.restartUpdate": "重启以更新", "release.applying": "正在应用更新…", @@ -2328,7 +2328,7 @@ const de = { "release.unraidNote": "Bei Unraid stattdessen über die Community-Applications-Vorlage aktualisieren.", "release.download": "Herunterladen", "release.allReleases": "Alle Versionen", - "release.downloadUpdate": "Update herunterladen", + "release.updateNow": "Jetzt aktualisieren", "release.downloading": "Update wird heruntergeladen…", "release.restartUpdate": "Zum Aktualisieren neu starten", "release.applying": "Update wird angewendet…", @@ -2810,7 +2810,7 @@ const pt = { "release.unraidNote": "No Unraid, atualize pelo modelo do Community Applications.", "release.download": "Baixar", "release.allReleases": "Todas as versões", - "release.downloadUpdate": "Baixar atualização", + "release.updateNow": "Atualizar agora", "release.downloading": "Baixando atualização…", "release.restartUpdate": "Reiniciar para atualizar", "release.applying": "Aplicando atualização…", @@ -3293,7 +3293,7 @@ const id = { "release.unraidNote": "Di Unraid, perbarui melalui template Community Applications.", "release.download": "Unduh", "release.allReleases": "Semua versi", - "release.downloadUpdate": "Unduh pembaruan", + "release.updateNow": "Perbarui sekarang", "release.downloading": "Mengunduh pembaruan…", "release.restartUpdate": "Mulai ulang untuk memperbarui", "release.applying": "Menerapkan pembaruan…", diff --git a/tests/test_health_api.py b/tests/test_health_api.py index fd38368..c430d9f 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -17,3 +17,43 @@ def test_health_endpoints_report_ok(): assert "ffmpeg_configured" in body assert "jobs_dir" not in body assert "data_dir" not in body + + +# --- version source precedence (#421) --------------------------------------- +# +# The in-app updater replaces backend/ but never python/, where the installed +# dist metadata lives. If app_version() trusted that metadata, a self-updated +# install would keep reporting the old version and keep offering an update it +# had already applied. So the app-layer marker (static/version.json) wins. + + +def test_app_version_prefers_the_app_layer_marker(tmp_path, monkeypatch): + import app.main as main + + monkeypatch.setattr(main, "STATIC_DIR", tmp_path) + (tmp_path / "version.json").write_text('{"version": "9.9.9"}\n', encoding="utf-8") + assert main.app_version() == "9.9.9" + + +def test_app_version_tolerates_a_bom(tmp_path, monkeypatch): + # PowerShell's Set-Content -Encoding UTF8 emits a BOM, which json.loads + # rejects outright; the packaged writer avoids it but the repo-root copy + # written by the release workflow does not. + import app.main as main + + monkeypatch.setattr(main, "STATIC_DIR", tmp_path) + (tmp_path / "version.json").write_text('{"version": "1.2.3"}\n', encoding="utf-8-sig") + assert main.app_version() == "1.2.3" + + +def test_app_version_falls_back_when_marker_is_absent_or_junk(tmp_path, monkeypatch): + # Docker images and source checkouts have no marker (it is gitignored) and + # must fall through to the hatch-vcs package metadata, not to a placeholder. + import app.main as main + + monkeypatch.setattr(main, "STATIC_DIR", tmp_path) + assert main.app_version() == main.package_version("stemdeck") + + for junk in ('{"version": ""}', '{"version": null}', "{}", "not json"): + (tmp_path / "version.json").write_text(junk, encoding="utf-8") + assert main.app_version() == main.package_version("stemdeck"), junk From 766c3e68be4d8bdbb0ac6539cb5dd4f8fdd8e95a Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 10:32:22 +0100 Subject: [PATCH 3/8] fix: address code-quality review on the version-source change (#421) Both findings from the automated review were fair. Narrow the bare `except Exception: pass` in app_version() to (OSError, ValueError, AttributeError). That is bandit B110, which this repo's own security conventions call out. The three cover every real failure here -- absent or unreadable file, invalid JSON or bad encoding, and valid JSON that is not an object so has no .get -- while letting an actual bug in the function surface instead of silently degrading the reported version. Bandit now reports no issues for the file. Use one import style in test_health_api.py so app.main is no longer imported both as `import app.main as main` and `from app.main import app` in the same module. Also added a "[]" case: JSON that parses but is not an object, which is the AttributeError branch the narrowed except now names explicitly. --- app/main.py | 8 +++++++- tests/test_health_api.py | 9 +++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 3e0a56d..7c2043b 100644 --- a/app/main.py +++ b/app/main.py @@ -111,7 +111,13 @@ def app_version() -> str: packaged = json.loads(raw).get("version") if isinstance(packaged, str) and packaged.strip(): return packaged.strip() - except Exception: + except (OSError, ValueError, AttributeError): + # Absent or unreadable (OSError), not valid JSON or not decodable + # (ValueError), or valid JSON that is not an object so has no .get + # (AttributeError). All of those simply mean "no app-layer marker + # here", so fall through to the metadata below. Narrow rather than a + # bare except so a genuine bug in this function still surfaces instead + # of silently degrading the reported version (bandit B110). pass # Installed package metadata (set at install/build from the tag); then the # generated app/_version.py for non-installed runs, then a dev placeholder. diff --git a/tests/test_health_api.py b/tests/test_health_api.py index c430d9f..52eb34c 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -28,7 +28,7 @@ def test_health_endpoints_report_ok(): def test_app_version_prefers_the_app_layer_marker(tmp_path, monkeypatch): - import app.main as main + from app import main monkeypatch.setattr(main, "STATIC_DIR", tmp_path) (tmp_path / "version.json").write_text('{"version": "9.9.9"}\n', encoding="utf-8") @@ -39,7 +39,7 @@ def test_app_version_tolerates_a_bom(tmp_path, monkeypatch): # PowerShell's Set-Content -Encoding UTF8 emits a BOM, which json.loads # rejects outright; the packaged writer avoids it but the repo-root copy # written by the release workflow does not. - import app.main as main + from app import main monkeypatch.setattr(main, "STATIC_DIR", tmp_path) (tmp_path / "version.json").write_text('{"version": "1.2.3"}\n', encoding="utf-8-sig") @@ -49,11 +49,12 @@ def test_app_version_tolerates_a_bom(tmp_path, monkeypatch): def test_app_version_falls_back_when_marker_is_absent_or_junk(tmp_path, monkeypatch): # Docker images and source checkouts have no marker (it is gitignored) and # must fall through to the hatch-vcs package metadata, not to a placeholder. - import app.main as main + from app import main monkeypatch.setattr(main, "STATIC_DIR", tmp_path) assert main.app_version() == main.package_version("stemdeck") - for junk in ('{"version": ""}', '{"version": null}', "{}", "not json"): + # "[]" parses fine but has no .get -- the narrowed except must still catch it. + for junk in ('{"version": ""}', '{"version": null}', "{}", "not json", "[]"): (tmp_path / "version.json").write_text(junk, encoding="utf-8") assert main.app_version() == main.package_version("stemdeck"), junk From 8238eb02a71d19c5532644b11a776f8ea0ac68c4 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 19:19:04 +0100 Subject: [PATCH 4/8] feat(updater): extend the in-app update to Linux (#421) Linux ships the same shape as Windows -- executable, backend/ and python/ side by side -- so the updater generalises rather than needing a second design. The platform-specific parts are now three small seams: the archive format, the executable name, and one new gate. Rust: - widen the updater's cfg gates from `windows` to `any(windows, linux)`, and replace extract_zip_archive with extract_update_archive, which uses zip on Windows and the existing extract_tar_archive on Linux - APP_EXE_NAME so the swap and the leftover sweep stop hardcoding StemDeck.exe - stop_backend_and_wait now sends SIGTERM and waits before escalating on unix, matching what stop_backend already does on window close - new app_root_is_writable gate: packaging/linux/install.sh offers a global install into /opt/stemdeck, which is root-owned while the app runs as the user. check_app_update declines up front rather than failing part way through a swap. Windows portable installs are user-writable by construction, but the probe is cheap and honest on both. tar rather than zip on Linux is deliberate: it preserves the executable bit. A zip would land StemDeck without +x and the relaunch after an update would fail with a permission error. Packaging (scripts/linux/make-portable.sh): - write python/runtime-version.json using the same uv.lock + interpreter major.minor formula as the Windows script, so the compatibility gate behaves identically on both - bring the strip to parity: stdlib test/, per-package test/tests, a post-strip import check, and a final __pycache__ sweep after the last interpreter run - PUBLISH_UPDATER_ASSETS=1 emits the slim app-layer tarball, its checksum and the runtime marker; wired into the CPU build in linux-release.yml since StemDeck and backend/ are identical between both variants Frontend: updaterAssetNames() maps the platform to its asset names, and the wiring is gated on that rather than on os === "windows". macOS is deliberately still excluded, and the comments now say why rather than just that it is: backend_dir() resolves the backend inside the downloaded runtime pack rather than the .app, so its app layer is a different thing and the existing runtime-pack updater already covers most of it. Verified: both platforms compile clean with no new clippy warnings, 42 tests on Windows and 43 on Linux (the extra one is the read-only-root gate, which is meaningless on Windows). The app-layer archive was round-tripped on Linux to confirm it contains exactly StemDeck + backend/, that python/ does not leak into it, that the executable bit survives, and that replacing a running binary works. Not yet run end to end against a real Linux release. --- .github/workflows/linux-release.yml | 7 + desktop/src-tauri/src/main.rs | 196 ++++++++++++++++++++++------ scripts/linux/make-portable.sh | 62 +++++++++ static/js/catalog.js | 44 +++++-- 4 files changed, 256 insertions(+), 53 deletions(-) diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 46284a2..895c65f 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -102,8 +102,12 @@ jobs: - name: build Linux CPU run: | + # PUBLISH_UPDATER_ASSETS=1 also emits the slim app-layer asset the + # in-app updater downloads (#421). Built once here: StemDeck and + # backend/ are identical in both variants. PACKAGE_NAME=StemDeck-Linux-x64 \ PACKAGE_VERSION="$VERSION" \ + PUBLISH_UPDATER_ASSETS=1 \ bash scripts/linux/make-portable.sh # Drop the uncompressed stage but keep the tarball; frees disk for the # larger NVIDIA build. The Tauri binary under target/ is preserved. @@ -145,3 +149,6 @@ jobs: dist/StemDeck-Linux-x64.tar.gz.sha256 dist/StemDeck-Linux-x64.NVIDIA.tar.gz dist/StemDeck-Linux-x64.NVIDIA.tar.gz.sha256 + dist/StemDeck-Linux-x64-app.tar.gz + dist/StemDeck-Linux-x64-app.tar.gz.sha256 + dist/StemDeck-Linux-x64-runtime-version.json diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index baf158f..3b7d36a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -19,10 +19,30 @@ use tauri_plugin_store::StoreExt; use zip::ZipArchive; const SETUP_VERSION: u64 = 1; -// Where the in-app updater stages its download before applying it (#421). -// Shared between the download and apply halves, hence a constant. + +// ── In-app updater platform support (#421) ────────────────────────────────── +// +// Windows and Linux ship the same shape: a flat directory with the executable, +// `backend/` and `python/` side by side, which is exactly what the swap needs. +// +// macOS is deliberately excluded. There `backend_dir()` resolves the backend +// inside the downloaded runtime pack rather than the .app, so the app layer is +// a different thing entirely and the existing runtime-pack updater already +// covers most of it. Treating it as "the same but with .app" would be wrong. +// +// The archive format differs because each platform's packaging script already +// produces one: Compress-Archive on Windows, tar on Linux. #[cfg(windows)] const UPDATE_APP_ARCHIVE: &str = "stemdeck-update-app.zip"; +#[cfg(target_os = "linux")] +const UPDATE_APP_ARCHIVE: &str = "stemdeck-update-app.tar.gz"; + +/// The shipped executable's filename. Defined for every platform so the +/// leftover sweep does not need its own cfg dance. +#[cfg(windows)] +const APP_EXE_NAME: &str = "StemDeck.exe"; +#[cfg(not(windows))] +const APP_EXE_NAME: &str = "StemDeck"; // Windows FFmpeg comes from BtbN's GitHub build (served via GitHub's CDN, far // faster worldwide than the old gyan.dev single mirror -- #248). Unlike gyan.dev, // which published a per-file `{url}.sha256` companion, BtbN publishes ONE combined @@ -201,7 +221,7 @@ struct RuntimeArchive { /// re-resolve "what is the latest version" itself. /// /// There is no runtime artifact here on purpose. The updater replaces -/// StemDeck.exe and backend/ only -- python/ is never touched, because an +/// the executable and backend/ only -- python/ is never touched, because an /// NVIDIA install rewrites it with CUDA torch at first run and replacing the /// directory would silently drop that machine back to CPU. The frontend gates /// on the release's runtime id first, and falls back to the full-package @@ -822,7 +842,7 @@ fn sweep_update_leftovers() { let _ = fs::remove_dir_all(&stale); } } - let stale_exe = root.join("StemDeck.exe.old"); + let stale_exe = root.join(format!("{APP_EXE_NAME}.old")); if stale_exe.is_file() { let _ = fs::remove_file(&stale_exe); } @@ -830,7 +850,7 @@ fn sweep_update_leftovers() { /// The Python dependency-set id of the runtime currently on disk, written into /// `python/runtime-version.json` by make-portable.ps1. `None` when the marker -/// is absent -- a pre-#421 install, a non-Windows build, or a source checkout. +/// is absent -- a pre-#421 install, a macOS build, or a source checkout. /// /// The frontend compares this against the release's published runtime id and /// only offers an in-app update when they match, since the updater cannot @@ -852,7 +872,7 @@ fn parse_runtime_id(text: &str) -> Option { } /// Decides whether the latest release can be applied in place, and resolves its -/// checksum. Windows-only; every other platform reports unsupported. +/// checksum. Windows and Linux; every other platform reports unsupported. /// /// An in-app update is offered only when the release's Python dependency set /// matches the installed one, because the updater cannot replace `python/` @@ -862,16 +882,16 @@ fn parse_runtime_id(text: &str) -> Option { /// whose imports the installed runtime cannot satisfy. #[tauri::command] async fn check_app_update(query: AppUpdateQuery) -> Result { - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "linux")))] { let _ = query; Ok(AppUpdateAvailability { supported: false, - reason: Some("in-app updates are Windows-only".to_string()), + reason: Some("in-app updates are not available on this platform".to_string()), ..Default::default() }) } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] { let unsupported = |reason: &str| { Ok(AppUpdateAvailability { @@ -881,6 +901,17 @@ async fn check_app_update(query: AppUpdateQuery) -> Result { + return unsupported("this install is not writable by the current user"); + } + Err(e) => return unsupported(&format!("could not resolve the app directory: {e}")), + _ => {} + } + let Some(installed) = installed_runtime_id() else { return unsupported("this install records no runtime id"); }; @@ -915,7 +946,7 @@ async fn check_app_update(query: AppUpdateQuery) -> Result Result { const MAX_BYTES: usize = 64 * 1024; let client = reqwest::Client::builder() @@ -945,26 +976,26 @@ async fn fetch_text(url: &str) -> Result { /// make-portable.ps1 writes (Get-FileHash + Set-Content). Rejects anything that /// is not exactly one 64-char hex digest so a redirect to an HTML error page /// can never be mistaken for a checksum. -#[cfg(any(windows, test))] +#[cfg(any(windows, target_os = "linux", test))] fn parse_sha256_line(text: &str) -> Option { let token = text.split_whitespace().next()?.to_ascii_lowercase(); let ok = token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()); ok.then_some(token) } -/// Downloads and checksum-verifies the app-layer update. Windows-only: the -/// portable zip is the only distribution shaped for an in-place file swap. +/// Downloads and checksum-verifies the app-layer update. Windows and Linux +/// only: both ship a flat directory shaped for an in-place file swap. #[tauri::command] async fn download_app_update( plan: AppUpdatePlan, app_handle: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "linux")))] { let _ = (plan, app_handle); - Err("in-app updates are only available on Windows portable installs".to_string()) + Err("in-app updates are not available on this platform".to_string()) } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] { let data_dir = local_data_dir()?; let downloads = data_dir.join("downloads"); @@ -985,7 +1016,7 @@ async fn download_app_update( /// (from the release's own published `.sha256` companion file, resolved by /// the frontend) before it is ever extracted. On mismatch the file is removed /// so a corrupt or tampered download can never be applied. -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] fn verify_update_sha256(path: &Path, expected: &str, label: &str) -> Result<(), String> { let actual = sha256_file(path)?; if !actual.eq_ignore_ascii_case(expected.trim()) { @@ -998,25 +1029,63 @@ fn verify_update_sha256(path: &Path, expected: &str, label: &str) -> Result<(), Ok(()) } -#[cfg(windows)] -fn extract_zip_archive(archive: &Path, destination: &Path) -> Result<(), String> { - let file = fs::File::open(archive) - .map_err(|e| format!("failed to open {}: {e}", archive.display()))?; - let mut zip = ZipArchive::new(file) - .map_err(|e| format!("failed to read zip {}: {e}", archive.display()))?; - zip.extract(destination) - .map_err(|e| format!("failed to extract {}: {e}", archive.display())) +/// Unpack the downloaded app layer into `destination`, in whichever format +/// this platform's packaging script produces. Both shapes put `StemDeck[.exe]` +/// and `backend/` at the archive root, so the caller sees the same layout. +/// +/// tar is used on Linux rather than zip specifically because it preserves the +/// executable bit; a zip would land StemDeck without +x and the relaunch would +/// fail with a permission error. +#[cfg(any(windows, target_os = "linux"))] +fn extract_update_archive(archive: &Path, destination: &Path) -> Result<(), String> { + fs::create_dir_all(destination) + .map_err(|e| format!("failed to create {}: {e}", destination.display()))?; + #[cfg(windows)] + { + let file = fs::File::open(archive) + .map_err(|e| format!("failed to open {}: {e}", archive.display()))?; + let mut zip = ZipArchive::new(file) + .map_err(|e| format!("failed to read zip {}: {e}", archive.display()))?; + zip.extract(destination) + .map_err(|e| format!("failed to extract {}: {e}", archive.display())) + } + #[cfg(target_os = "linux")] + { + extract_tar_archive(archive, destination) + } +} + +/// Whether this install can rewrite its own files. +/// +/// `packaging/linux/install.sh` offers a global install into `/opt/stemdeck`, +/// which is root-owned while the app runs as the user. Renaming the binary +/// there fails, so the updater has to decline up front and send the user to the +/// normal download rather than discovering it half way through a swap. Windows +/// portable installs are user-writable by construction, but the probe is cheap +/// and honest on both. +#[cfg(any(windows, target_os = "linux"))] +fn app_root_is_writable(root: &Path) -> bool { + let probe = root.join(".stemdeck-update-probe"); + match fs::File::create(&probe) { + Ok(_) => { + let _ = fs::remove_file(&probe); + true + } + Err(_) => false, + } } /// Stops the backend and waits for the process to actually exit. /// /// The regular `stop_backend` hands the kill to a background thread and /// returns immediately -- fine on window close, wrong here. The backend runs -/// *from* the very directories the update is about to rename: its interpreter +/// *from* the very directories the update is about to replace: its interpreter /// is `python/`, its code is `backend/`. Windows refuses to rename a directory -/// while a handle inside it is open, so starting the swap before the process -/// is gone fails with a permission error, or worse, part-way through. -#[cfg(windows)] +/// while a handle inside it is open, so starting the swap before the process is +/// gone fails with a permission error, or worse, part-way through. Linux would +/// tolerate it, but a backend still serving requests from a directory being +/// swapped out is not something to rely on either. +#[cfg(any(windows, target_os = "linux"))] fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(), String> { let handles = match state.inner.lock() { Ok(mut guard) => guard.handles.take(), @@ -1025,6 +1094,21 @@ fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(), let Some(mut handles) = handles else { return Ok(()); }; + // Give uvicorn a chance to drain in-flight requests before escalating, + // matching what stop_backend does on window close. + #[cfg(unix)] + { + // SAFETY: the child was spawned by us and has not been waited on, so + // its pid is still valid. + unsafe { libc::kill(handles.child.id() as libc::pid_t, libc::SIGTERM) }; + let grace = Instant::now() + Duration::from_secs(3); + while Instant::now() < grace { + if handles.child.try_wait().ok().flatten().is_some() { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + } let _ = handles.child.kill(); let deadline = Instant::now() + timeout; loop { @@ -1049,7 +1133,7 @@ fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(), /// indexer can hold a transient handle inside a directory that was just /// written or is about to move. These clear in well under a second; without a /// retry an unlucky scan turns into a failed update mid-swap. -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] fn rename_with_retry(from: &Path, to: &Path, what: &str) -> Result<(), String> { const ATTEMPTS: u32 = 10; let mut last_err = None; @@ -1079,7 +1163,7 @@ fn rename_with_retry(from: &Path, to: &Path, what: &str) -> Result<(), String> { /// /// Only reachable from an explicit "Restart to update" user action, never a /// background timer, and the backend is stopped first, so this can never land -/// mid-job. Only `StemDeck.exe` and `backend/` are replaced: `python/`, +/// mid-job. Only the executable and `backend/` are replaced: `python/`, /// `portable.txt`, `cpu-only` and `data/` are all left exactly as they are, so /// an NVIDIA install keeps its CUDA torch and portable/GPU detection and user /// data all survive untouched. @@ -1088,12 +1172,12 @@ fn apply_app_update( state: tauri::State, app_handle: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "linux")))] { let _ = (state, app_handle); - Err("in-app updates are only available on Windows portable installs".to_string()) + Err("in-app updates are not available on this platform".to_string()) } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] { let root = app_root()?; let data_dir = local_data_dir()?; @@ -1119,12 +1203,12 @@ fn apply_app_update( } let staged = (|| -> Result { - extract_zip_archive(&app_archive, &staging)?; - let new_exe = staging.join("StemDeck.exe"); + extract_update_archive(&app_archive, &staging)?; + let new_exe = staging.join(APP_EXE_NAME); if !staging.join("backend").join("app").is_dir() || !new_exe.is_file() { - return Err( - "app update archive did not contain StemDeck.exe and backend/app".to_string(), - ); + return Err(format!( + "app update archive did not contain {APP_EXE_NAME} and backend/app" + )); } Ok(new_exe) })(); @@ -1145,8 +1229,8 @@ fn apply_app_update( let backend_dir = root.join("backend"); let backend_old = root.join("backend.old"); - let exe_path = root.join("StemDeck.exe"); - let exe_old = root.join("StemDeck.exe.old"); + let exe_path = root.join(APP_EXE_NAME); + let exe_old = root.join(format!("{APP_EXE_NAME}.old")); if backend_old.exists() { fs::remove_dir_all(&backend_old) .map_err(|e| format!("failed to remove {}: {e}", backend_old.display()))?; @@ -4281,6 +4365,36 @@ mod tests { // clear_webkit_data suppresses NotFound — this is the correct behavior. } + #[cfg(any(windows, target_os = "linux"))] + #[test] + fn a_writable_app_root_is_detected() { + let dir = make_tmp(); + assert!(super::app_root_is_writable(dir.path())); + // the probe must not leave anything behind + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); + } + + // install.sh --global puts the package in /opt, root-owned, while the app + // runs as the user. The updater has to decline up front rather than fail + // part way through the swap. + #[cfg(target_os = "linux")] + #[test] + fn a_read_only_app_root_is_rejected() { + use std::os::unix::fs::PermissionsExt; + let dir = make_tmp(); + let mut perms = fs::metadata(dir.path()).unwrap().permissions(); + perms.set_mode(0o555); + fs::set_permissions(dir.path(), perms).unwrap(); + let writable = super::app_root_is_writable(dir.path()); + let mut restore = fs::metadata(dir.path()).unwrap().permissions(); + restore.set_mode(0o755); + fs::set_permissions(dir.path(), restore).unwrap(); + assert!( + !writable, + "a root-owned install must not be offered an in-place update" + ); + } + // --- In-app updater runtime-compatibility marker (#421) --- #[test] diff --git a/scripts/linux/make-portable.sh b/scripts/linux/make-portable.sh index 2400b62..1a6db7c 100755 --- a/scripts/linux/make-portable.sh +++ b/scripts/linux/make-portable.sh @@ -183,6 +183,44 @@ done # Static link archives are only needed to build C++ extensions, never to run. find "$TORCH_LIB" -name "*.a" -type f -delete 2>/dev/null || true +# The stdlib's own test suite and every package's bundled test/tests directory +# are never imported at runtime -- on Windows this accounted for ~2,000 files +# and 33 MB (#421). Mirrored here so both platforms ship the same shape. +rm -rf "${PYTHON_DIR}/lib/python${PYTHON_VERSION}/test" 2>/dev/null || true +for d in "${PYTHON_DIR}/lib/python${PYTHON_VERSION}/site-packages"/*/; do + for name in test tests; do + rm -rf "${d}${name}" 2>/dev/null || true + done +done +# NOTE: do NOT strip .dist-info RECORD files. pip needs them to replace a +# package, and the NVIDIA variant pip-installs CUDA torch into this very tree on +# the user's first run (install_cuda_torch). + +# Re-verify after the widened strip: the check above ran before it and would not +# catch a strip that removed something load-bearing (#407, #421). +"$BUNDLED_PYTHON" -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile, audio_separator, onnxruntime; print('Post-strip import check OK')" + +# Runtime fingerprint, shipped inside python/ in every package (#421). +# +# The in-app updater's SAFETY GATE, not a download trigger: it replaces +# StemDeck + backend/ only and never touches python/, so an app-only update is +# safe exactly when the release needs the same Python dependencies already +# installed. Derived from uv.lock plus the interpreter's major.minor -- the same +# formula make-portable.ps1 uses, deliberately not the package version, which +# changes every release and would make every update look incompatible. +RUNTIME_ID="py${PYTHON_VERSION}-$(sha256sum "${REPO_ROOT}/uv.lock" | cut -c1-16)" +printf '{"runtimeId":"%s"}\n' "$RUNTIME_ID" > "${PYTHON_DIR}/runtime-version.json" +echo "==> Runtime id: ${RUNTIME_ID}" + +# Importing above rewrote __pycache__ for everything it touched; on Windows that +# measured 1,912 files / 39 MB and cancelled out most of the strip. Drop it once +# here, after the last time this script runs the packaged interpreter. +find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true +find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true +# A developer's local __pycache__ rides along in the cp -R above, and it is dead +# weight in the small update asset that exists precisely to stay small. +find "$BACKEND_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true + echo "==> Building Tauri desktop binary" if [[ "$SKIP_TAURI_BUILD" != "1" ]]; then pushd "$REPO_ROOT/desktop" >/dev/null @@ -206,6 +244,30 @@ echo "==> Creating archive" tar -czf "$ARCHIVE_PATH" -C "${REPO_ROOT}/${OUTPUT_ROOT}" "$PACKAGE_NAME" ( cd "${REPO_ROOT}/${OUTPUT_ROOT}" && sha256sum "${PACKAGE_NAME}.tar.gz" > "${PACKAGE_NAME}.tar.gz.sha256" ) +# Slim "app layer" asset for the in-app updater (#421). The full archive above +# is untouched and stays the only thing a fresh install needs; this is an extra, +# much smaller asset used only by an already-installed app updating itself, so +# it never re-extracts the whole Python runtime for a release that only changed +# app code. +# +# StemDeck and backend/ are identical between the CPU and NVIDIA variants -- the +# only per-variant difference in the package is the `cpu-only` marker at the +# root, which the updater never touches -- so this is built once, from whichever +# invocation sets PUBLISH_UPDATER_ASSETS=1, and needs no variant suffix. +# +# tar rather than zip so the executable bit on StemDeck survives; a zip would +# land it without +x and the relaunch after an update would fail. +if [[ "${PUBLISH_UPDATER_ASSETS:-0}" == "1" ]]; then + echo "==> Creating updater app-layer asset" + UPDATER_APP_NAME="StemDeck-Linux-x64-app" + ( cd "$STAGE" && tar -czf "${REPO_ROOT}/${OUTPUT_ROOT}/${UPDATER_APP_NAME}.tar.gz" StemDeck backend ) + ( cd "${REPO_ROOT}/${OUTPUT_ROOT}" \ + && sha256sum "${UPDATER_APP_NAME}.tar.gz" > "${UPDATER_APP_NAME}.tar.gz.sha256" ) + cp "${PYTHON_DIR}/runtime-version.json" \ + "${REPO_ROOT}/${OUTPUT_ROOT}/StemDeck-Linux-x64-runtime-version.json" + echo "Updater app pack : ${REPO_ROOT}/${OUTPUT_ROOT}/${UPDATER_APP_NAME}.tar.gz" +fi + echo "==> Done" if [[ "$CPU_ONLY" == "1" ]]; then echo "Variant : CPU-only" diff --git a/static/js/catalog.js b/static/js/catalog.js index ee06691..9f53062 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -2266,13 +2266,17 @@ function pickReleaseAsset(release, target) { return asset ? { url: asset.browser_download_url, name } : null; } -// ─── Windows in-app updater ─── +// ─── In-app updater ─── // Downloads and applies an update without leaving the app, instead of sending // the user to a browser download -- see download_app_update/apply_app_update in -// desktop/src-tauri/src/main.rs for the file swap. Windows-only: the portable -// zip is the only distribution shaped for an in-place swap. +// desktop/src-tauri/src/main.rs for the file swap. // -// The updater replaces StemDeck.exe and backend/ ONLY. It never touches +// Windows and Linux only. Both ship a flat directory with the executable, +// backend/ and python/ side by side, which is the shape the swap needs. macOS +// resolves its backend inside the downloaded runtime pack rather than the .app, +// so its app layer is a different thing entirely and is handled separately. +// +// The updater replaces the executable and backend/ ONLY. It never touches // python/, because an NVIDIA install rewrites that directory with CUDA torch on // first run and replacing it would silently drop the machine back to CPU. So an // in-app update is only safe when the release needs the same Python @@ -2283,6 +2287,20 @@ function findReleaseAsset(release, name) { return (release.assets || []).find((a) => a.name === name) || null; } +// Asset names the packaging scripts publish for each in-place-updatable +// platform. The archive format differs because each script already produces +// one: Compress-Archive on Windows, tar on Linux (which also preserves the +// executable bit the relaunch depends on). +function updaterAssetNames(target) { + if (target.os === "windows") { + return { app: "StemDeck-Windows-x64-app.zip", runtimeId: "StemDeck-Windows-x64-runtime-version.json" }; + } + if (target.os === "linux") { + return { app: "StemDeck-Linux-x64-app.tar.gz", runtimeId: "StemDeck-Linux-x64-runtime-version.json" }; + } + return null; +} + // Resolves the app-layer asset for the in-app updater, or null when this // release cannot be applied in place -- it predates the updater assets, is // missing one, or changed the Python dependency set. Null means "use the full @@ -2294,10 +2312,12 @@ function findReleaseAsset(release, name) { // objects.githubusercontent.com hosts that serve release *assets*. Fetching // them from JS is blocked outright; Rust's HTTP client is not bound by the page // CSP, so the policy stays as tight as it is today. -async function resolveWindowsUpdatePlan(release) { - const appAsset = findReleaseAsset(release, "StemDeck-Windows-x64-app.zip"); - const appShaAsset = findReleaseAsset(release, "StemDeck-Windows-x64-app.zip.sha256"); - const runtimeIdAsset = findReleaseAsset(release, "StemDeck-Windows-x64-runtime-version.json"); +async function resolveInAppUpdatePlan(release, target) { + const names = updaterAssetNames(target); + if (!names) return null; + const appAsset = findReleaseAsset(release, names.app); + const appShaAsset = findReleaseAsset(release, `${names.app}.sha256`); + const runtimeIdAsset = findReleaseAsset(release, names.runtimeId); if (!appAsset || !appShaAsset || !runtimeIdAsset) return null; const check = await window.__TAURI__.core.invoke("check_app_update", { @@ -2324,7 +2344,7 @@ function showInappError(message) { // Wires the download/apply buttons for a resolved plan. Returns false (and // touches nothing) when this release has no in-app-updatable assets, so the // caller can fall back to the plain download link. -async function wireWindowsUpdate() { +async function wireInAppUpdate(target) { const downloadBtn = document.getElementById("releaseDownloadApp"); const applyBtn = document.getElementById("releaseApplyUpdate"); const inapp = document.getElementById("releaseInapp"); @@ -2333,7 +2353,7 @@ async function wireWindowsUpdate() { const errorEl = document.getElementById("releaseInappError"); if (!downloadBtn || !applyBtn || !latestRelease) return false; - const plan = await resolveWindowsUpdatePlan(latestRelease); + const plan = await resolveInAppUpdatePlan(latestRelease, target); if (!plan) return false; // The manual download stays visible alongside the auto-update pill: some @@ -2439,9 +2459,9 @@ async function openReleaseDialog() { download.classList.remove("hidden"); let usedInapp = false; - if (target.os === "windows") { + if (updaterAssetNames(target)) { try { - usedInapp = await wireWindowsUpdate(); + usedInapp = await wireInAppUpdate(target); } catch (e) { console.warn("[catalog] in-app update setup failed, falling back to link:", e); } From cde9e6b1275d53ea348a90c70caddffdd157b087 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 19:33:45 +0100 Subject: [PATCH 5/8] fix(updater): see pre-releases, and compile the Rust in CI (#421) Two gaps that would each have undermined the update flow on release day. The update check polled /releases/latest, which GitHub defines as the most recent NON-PRERELEASE, non-draft release. Ship a version with the pre-release box ticked and it becomes invisible: no notification, no update button, on any platform, with nothing in the logs to explain it. StemDeck has always published even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false), which is the only reason this has not bitten yet -- it was a trap waiting on someone ticking a box. Now polls the releases list and takes the newest non-draft, so it is correct either way. Drafts stay excluded: they are already invisible unauthenticated, and a maintainer should not be offered a release whose assets do not exist yet. windows-check.yml and macos-check.yml now also run on pull requests that touch desktop/src-tauri/**, not workflow_dispatch only. This PR added roughly 600 lines of mostly cfg-gated Rust across two commits and every CI check passed without compiling a single line of it; the comment at the top of windows-check.yml notes that exact gap already shipped a broken Windows build in v0.11.1's first release attempt. Scoped by path so the self-hosted runners see no extra load from the majority of PRs, which never go near src-tauri. This also gets the macOS branch compiled for the first time. Local verification covered Windows and Linux, so the cfg(not(any(windows, linux))) arm of the three updater commands has never been near a compiler. --- .github/workflows/macos-check.yml | 9 +++++++++ .github/workflows/windows-check.yml | 9 +++++++++ static/js/catalog.js | 16 ++++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos-check.yml b/.github/workflows/macos-check.yml index 2bc8755..de67099 100644 --- a/.github/workflows/macos-check.yml +++ b/.github/workflows/macos-check.yml @@ -8,6 +8,15 @@ name: macOS Rust Check # only way to get a real compiler pass over it before merging. on: workflow_dispatch: + # Also on PRs that actually touch the Rust, so a desktop-shell change cannot + # merge without a real compiler pass on this platform. Scoped by path so the + # self-hosted runner's load is unchanged for the majority of PRs, which do not + # go near src-tauri. (#421 added ~600 lines of mostly cfg-gated Rust and every + # CI check passed without compiling any of it.) + pull_request: + paths: + - "desktop/src-tauri/**" + - ".github/workflows/macos-check.yml" permissions: {} diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml index 1d352c1..a567d29 100644 --- a/.github/workflows/windows-check.yml +++ b/.github/workflows/windows-check.yml @@ -9,6 +9,15 @@ name: Windows Rust Check # build in v0.11.1's first release attempt. on: workflow_dispatch: + # Also on PRs that actually touch the Rust, so a desktop-shell change cannot + # merge without a real compiler pass on this platform. Scoped by path so the + # self-hosted runner's load is unchanged for the majority of PRs, which do not + # go near src-tauri. (#421 added ~600 lines of mostly cfg-gated Rust and every + # CI check passed without compiling any of it.) + pull_request: + paths: + - "desktop/src-tauri/**" + - ".github/workflows/windows-check.yml" permissions: {} diff --git a/static/js/catalog.js b/static/js/catalog.js index 9f53062..ce82c01 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -2060,7 +2060,14 @@ const FALLBACK_VERSION = "0.1.0"; let currentVersion = FALLBACK_VERSION; const REPO_URL = "https://github.com/stemdeckapp/stemdeck"; const RELEASES_URL = "https://github.com/stemdeckapp/stemdeck/releases"; -const RELEASES_API = "https://api.github.com/repos/stemdeckapp/stemdeck/releases/latest"; +// The releases LIST, not /releases/latest. GitHub defines "latest" as the most +// recent NON-PRERELEASE release, so the moment a version ships with the +// pre-release box ticked it becomes invisible here and nobody is ever told an +// update exists. StemDeck has historically published even its alphas as normal +// releases, which is why that has not bitten yet -- this makes the check +// correct either way rather than dependent on remembering not to tick a box. +const RELEASES_API = + "https://api.github.com/repos/stemdeckapp/stemdeck/releases?per_page=10"; const DISMISSED_UPDATE_KEY = "stemdeck.dismissed_update"; // The full GitHub release object from the last successful update check, used to @@ -2497,7 +2504,12 @@ async function checkForUpdate() { // 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(); + // Newest first, as GitHub returns them. Drafts are invisible to an + // unauthenticated request anyway, but filter them so a maintainer running a + // dev build is not offered a release that has no assets yet. + const releases = await res.json(); + const data = Array.isArray(releases) ? releases.find((r) => !r.draft) : null; + if (!data) return; const latest = normalizeVersion(data.tag_name); // Compare canonically so a PEP440 current version (0.7.0a9) matches the // release tag form (0.7.0-alpha.9) and we don't nag an already-current app. From 23b91cc4512d3982abf35d5742715b6fb22cebec Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 19:37:57 +0100 Subject: [PATCH 6/8] test(e2e): match the releases-list shape the app now polls (#421) The update-check stub returned a single release object, which was right for /releases/latest. The app now polls the releases list so a pre-release is still seen, so the fixture has to return an array or checkForUpdate bails and the release card never appears. Caught by frontend-e2e on the previous commit, which is the suite doing exactly its job: the only assertion that covers this path is report-failure.spec.mjs:98, and it went red immediately. --- tests/e2e/helpers.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs index f1d26fd..4a64972 100644 --- a/tests/e2e/helpers.mjs +++ b/tests/e2e/helpers.mjs @@ -169,7 +169,11 @@ export async function stubUpdateCheck(page, { available = false } = {}) { route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ tag_name: "v9.9.9", body: "notes", html_url: "https://example.invalid", assets: [] }), + // An ARRAY: the app polls /releases (the list), not /releases/latest, + // so that a version published as a pre-release is still seen. + body: JSON.stringify([ + { tag_name: "v9.9.9", draft: false, prerelease: false, body: "notes", html_url: "https://example.invalid", assets: [] }, + ]), })); return; } From 2d5c12ca30909a61f49c2eec98c1b505b82e0ec4 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 19:48:16 +0100 Subject: [PATCH 7/8] feat(i18n): add French, and make the runtime id line-ending independent French is a complete table, not a partial one: 435 keys, the same set German and Portuguese carry (English's 443 minus the ten Polish-only .few/.many forms and the bare upload.skippedFiles, plus singular forms for the three playlist.skip.* families). French takes the one/other buckets, so plural() needs no change. Verified with the checks from .claude/rules/i18n.md: the drift check reports clean, and separately there are zero {placeholder} mismatches and zero HTML tag mismatches against English. The 27 strings identical to English are genuinely identical in French (Piano, Solo, Transport, Position, LUFS, Standard, Port, the brand names, CUDA (NVIDIA), MPS (Apple Silicon)). Separately: the runtime id was being computed from the raw bytes of uv.lock, so a Windows checkout with core.autocrlf=true hashed CRLF and Linux hashed LF, and the same lockfile produced two different ids -- caught by building the Linux package and seeing py3.12-d74d6ef80c5e9d1f where Windows had produced py3.12-dbda45e38e1044cf. Each platform stayed self-consistent so the gate still worked, but the id would shift spuriously if a runner's autocrlf ever changed, silently declining app-only updates that were in fact compatible. Both scripts now hash the content with newlines normalised; PowerShell, bash and a reference Python implementation all agree on d74d6ef80c5e9d1f. --- scripts/linux/make-portable.sh | 5 +- scripts/windows/make-portable.ps1 | 13 +- static/js/i18n.js | 487 +++++++++++++++++++++++++++++- 3 files changed, 502 insertions(+), 3 deletions(-) diff --git a/scripts/linux/make-portable.sh b/scripts/linux/make-portable.sh index 1a6db7c..550db22 100755 --- a/scripts/linux/make-portable.sh +++ b/scripts/linux/make-portable.sh @@ -208,7 +208,10 @@ done # installed. Derived from uv.lock plus the interpreter's major.minor -- the same # formula make-portable.ps1 uses, deliberately not the package version, which # changes every release and would make every update look incompatible. -RUNTIME_ID="py${PYTHON_VERSION}-$(sha256sum "${REPO_ROOT}/uv.lock" | cut -c1-16)" +# tr -d '\r': hash the CONTENT with newlines normalised, not the bytes on +# disk, so a Windows checkout (CRLF) and a Linux one (LF) agree on the id for +# an identical lockfile. See the matching note in make-portable.ps1. +RUNTIME_ID="py${PYTHON_VERSION}-$(tr -d '\r' < "${REPO_ROOT}/uv.lock" | sha256sum | cut -c1-16)" printf '{"runtimeId":"%s"}\n' "$RUNTIME_ID" > "${PYTHON_DIR}/runtime-version.json" echo "==> Runtime id: ${RUNTIME_ID}" diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index c5f121d..4a07103 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -293,7 +293,18 @@ Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages") -Directory -Force # and NVIDIA variants by torch's local version tag alone (2.6.0+cpu vs 2.6.0) # even though their dependency requirements are identical. $PyMajorMinor = (& $PythonExe -c "import sys; print('%d.%d' % sys.version_info[:2])").Trim() -$LockHash = (Get-FileHash -Algorithm SHA256 -Path (Join-Path $Root "uv.lock")).Hash.Substring(0, 16).ToLower() +# Hash the CONTENT with newlines normalised, not the bytes on disk. A Windows +# checkout with core.autocrlf=true stores uv.lock as CRLF and Linux as LF, so +# hashing raw bytes produced a different id per platform for an identical +# lockfile -- and would shift spuriously if a runner's autocrlf ever changed, +# silently declining app-only updates that were in fact compatible. +$LockText = ([System.IO.File]::ReadAllText((Join-Path $Root "uv.lock")) -replace "`r", "") +$Sha = [System.Security.Cryptography.SHA256]::Create() +try { + $LockHash = [System.BitConverter]::ToString( + $Sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($LockText)) + ).Replace("-", "").Substring(0, 16).ToLower() +} finally { $Sha.Dispose() } $RuntimeId = "py$PyMajorMinor-$LockHash" $RuntimeIdJson = @{ runtimeId = $RuntimeId } | ConvertTo-Json -Compress [System.IO.File]::WriteAllText((Join-Path $PythonDir "runtime-version.json"), $RuntimeIdJson + "`n", $utf8NoBom) diff --git a/static/js/i18n.js b/static/js/i18n.js index 3d77b88..34ab19b 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -27,6 +27,7 @@ export const LANGUAGES = [ { code: "ja", flag: "🇯🇵", name: "日本語" }, { code: "zh-Hans", flag: "🇨🇳", name: "简体中文" }, { code: "de", flag: "🇩🇪", name: "Deutsch" }, + { code: "fr", flag: "🇫🇷", name: "Français" }, { code: "pt", flag: "🇧🇷", name: "Português" }, { code: "id", flag: "🇮🇩", name: "Bahasa Indonesia" }, ]; @@ -3561,4 +3562,488 @@ const id = { "resetConfirm.failedConnection": "Gagal mengatur ulang — periksa koneksi Anda.", }; -export const TRANSLATIONS = { en, pl, ja, "zh-Hans": zhHans, de, pt, id }; +const fr = { + "doc.title": "StemDeck — séparez n'importe quel morceau en pistes", + + "topbar.urlPlaceholder": "Collez un lien YouTube ou SoundCloud, ou déposez un fichier audio…", + "topbar.removeFile": "Retirer le fichier", + "topbar.uploadFile": "Importer un fichier audio", + "extract.label": "Extraire", + "extract.all": "Tout", + "process.splitStems": "Séparer les pistes", + + "stem.original": "Original", + "stem.vocals": "Voix", + "stem.lead_vocals": "Voix principale", + "stem.backing_vocals": "Chœurs", + "stem.drums": "Batterie", + "stem.bass": "Basse", + "stem.guitar": "Guitare", + "stem.piano": "Piano", + "stem.other": "Autre", + "stem.others": "Autres", + + "vocalMode.groupAria": "Mode voix", + "vocalMode.all": "Tout", + "vocalMode.split": "Principale + chœurs", + + "aria.mute": "Couper {name}", + "aria.solo": "Solo {name}", + "aria.soloOnly": "Solo uniquement {name}", + "aria.download": "Télécharger {name}", + "aria.volume": "Volume {name}", + + "notif.bell": "Notifications", + "notif.title": "Notifications", + "notif.clearAll": "Tout effacer", + "notif.close": "Fermer les notifications", + "notif.newRelease": "Nouvelle version disponible", + "notif.dismiss": "Ignorer la notification", + "notif.empty": "Aucune nouvelle notification", + + "nav.libraryNav": "Navigation de la bibliothèque", + "nav.toggleLibrary": "Afficher/masquer la bibliothèque", + "nav.library": "Bibliothèque", + "nav.favorites": "Favoris", + "nav.trash": "Corbeille", + "nav.importQueue": "File d'importation", + "nav.queue": "File d'attente", + "nav.settings": "Paramètres", + "nav.weRecommend": "Nos recommandations", + "nav.weRecommendLine1": "Nos", + "nav.weRecommendLine2": "recommandations", + "nav.help": "Aide", + + "search.ariaLabel": "Rechercher dans la bibliothèque", + "search.placeholder": "Rechercher ou #tag…", + "search.tagSuggestions": "Suggestions de tags", + "search.placeholderLibrary": "Rechercher dans la bibliothèque…", + "search.placeholderFavorites": "Rechercher dans les favoris…", + "search.placeholderTrash": "Rechercher dans la corbeille…", + "trash.empty": "Vider la corbeille", + "trash.isEmptyState": "La corbeille est vide", + "trash.noSearchMatch": "Aucun morceau supprimé ne correspond à votre recherche", + + "meta.key": "Tonalité", + "meta.bpm": "BPM", + "meta.lufs": "LUFS", + "meta.duration": "Durée", + "meta.scale": "Gamme", + "meta.dynamicRange": "Plage dynamique", + "meta.tempoStability": "Stabilité du tempo", + + "presence.vocal": "Présence de la voix", + "presence.drum": "Intensité de la batterie", + "presence.bass": "Profondeur de la basse", + "presence.guitar": "Présence de la guitare", + "presence.piano": "Présence du piano", + "presence.other": "Autre", + + "sections.title": "Sections", + "sections.add": "Ajouter", + "sections.addAria": "Ajouter une section", + "sections.savingAria": "Enregistrement des sections", + + "mixer.title": "Mixage", + "mixer.hint": "Glissez le fader · M/S", + "stemsPanel.ariaLabel": "Pistes", + + "beatgrid.toolbarAria": "Éditeur de grille rythmique", + "beatgrid.gridLabel": "GRILLE", + "beatgrid.toolAria": "Outil", + "beatgrid.move": "Déplacer", + "beatgrid.moveTitle": "Faire glisser les temps", + "beatgrid.add": "Ajouter", + "beatgrid.addTitle": "Cliquez pour ajouter un temps", + "beatgrid.delete": "Supprimer", + "beatgrid.deleteTitle": "Cliquez sur un temps pour le supprimer", + "beatgrid.barLine": "Barre de mesure", + "beatgrid.barLineTitle": "Cliquez sur un temps pour en faire un temps fort", + "beatgrid.ripple": "Répercuter", + "beatgrid.snap": "Aimanter", + "beatgrid.bar": "Mesure", + "beatgrid.barAria": "Temps par mesure dans cette région", + "beatgrid.undo": "Annuler", + "beatgrid.undoTitle": "Annuler (Cmd/Ctrl+Z)", + "beatgrid.redo": "Rétablir", + "beatgrid.redoTitle": "Rétablir (Cmd/Ctrl+Maj+Z)", + "beatgrid.reset": "Réinitialiser", + "beatgrid.resetTitle": "Abandonner toutes les modifications et restaurer la grille détectée", + "beatgrid.done": "Terminé", + "beatgrid.hint": "Faites glisser un temps pour recaler la région. Alt+glisser déplace un seul temps.", + + "wave.loadingAria": "Chargement de la forme d'onde", + "job.cancelAria": "Annuler la tâche", + "job.cancel": "Annuler", + + "footer.stemsPlaceholder": "— Pistes", + "footer.stemsCount": "{count} pistes", + "footer.stemsCount.one": "{count} piste", + "footer.stemsCount.other": "{count} pistes", + "footer.extractedLabel": "Extrait", + "footer.extracted": "Extrait {when}", + "fav.toggleAria": "Ajouter/retirer des favoris", + "footer.seekAria": "Se déplacer", + + "export.optionsAria": "Options d'export", + "export.formatAria": "Format d'export", + "export.includeClick": "Inclure le métronome", + "export.includeClickTitle": "Mixer le métronome dans le fichier exporté", + "export.addCountIn": "Ajouter un décompte (1 mesure)", + "export.addCountInTitle": "Ajouter une mesure de décompte avant l'audio", + "export.mix": "Exporter le mix", + "export.mixDesc": "Exporter l'audio mixé", + "export.mixDescVideo": "Exporter le mix avec la vidéo d'origine", + "export.failed": "Échec de l'export.", + "export.allMuted": "Toutes les pistes sont coupées - rien à exporter.", + "export.noStems": "Aucune piste à exporter.", + "export.mixing": "Export en cours…", + "export.allStems": "Exporter toutes les pistes", + "export.allStemsDesc": "Toutes les pistes dans un .zip", + "export.currentRegion": "Exporter la région actuelle", + "export.currentRegionDesc": "Exporter la région sélectionnée", + + "transport.group": "Transport", + "transport.stop": "Arrêter", + "transport.playPause": "Lecture / Pause", + + "position.group": "Position", + "position.loopTitle": "Boucler la position sélectionnée (L)", + "position.loopLabel": "Boucler la position", + "position.loopExactTitle": "Début / fin exacts de la boucle (mm:ss.mmm ou secondes)", + "position.loopStartAria": "Début de la boucle", + "position.loopEndAria": "Fin de la boucle", + + "speed.group": "Vitesse", + "speed.ariaLabel": "Vitesse de lecture", + + "click.group": "Métronome", + "click.alpha": "Alpha", + "click.toggleTitle": "Métronome (K)", + "click.toggleAria": "Métronome", + "click.unavailableAria": "Métronome indisponible", + "click.on": "ACTIVÉ", + "click.optionsAria": "Options du métronome", + "click.countIn": "Décompte", + "click.countInTitle": "Jouer une mesure de décompte avant le début du morceau lors de la lecture", + "click.barAccentAria": "Accentuer tous les N temps", + "click.auto": "Auto (détecté)", + "click.autoNone": "Auto (aucun trouvé)", + "click.off": "Désactivé", + "click.editTitle": "Modifier la grille rythmique", + "click.grid": "Grille", + "click.rateAria": "Cadence du métronome", + "click.rateTitle": "Diviser ou doubler la cadence du métronome", + "click.half": "Moitié du tempo", + "click.detected": "Tempo détecté", + "click.double": "Double tempo", + "click.volumeAria": "Volume du métronome", + "click.volumeTitle": "Volume du métronome : {pct}", + "click.reason.loadTrack": "Chargez un morceau pour utiliser le métronome", + "click.reason.playbackUnavailable": "Lecture indisponible pour ce morceau", + "click.reason.noBeatGrid": "Aucune grille rythmique pour ce morceau", + "click.reason.unavailableOnPath": "Métronome indisponible sur ce mode de lecture", + "click.reason.needsWebAudio": "Le métronome nécessite le moteur Web Audio", + + "about.title": "StemDeck", + "about.tagline": "Open source. Sans abonnement. Créé par des musiciens, pour des musiciens.", + "about.closeAria": "Fermer la fenêtre À propos", + "about.website": "Site web", + "about.github": "GitHub", + "about.discord": "Discord", + "about.reddit": "Reddit", + "about.instagram": "Instagram", + "about.x": "X", + + "friends.title": "Nos recommandations", + "friends.tagline": "Des gens formidables qui font de belles choses. Allez les rencontrer ❤️", + "friends.closeAria": "Fermer la fenêtre des recommandations", + + "release.title": "Nouvelle version disponible", + "release.closeAria": "Fermer la fenêtre de version", + "release.updateServer": "Mettez à jour votre serveur", + "release.pullImage": "Récupérez la nouvelle image et redémarrez le conteneur :", + "release.unraidNote": "Sur Unraid, mettez plutôt à jour via le modèle Community Applications.", + "release.download": "Télécharger", + "release.allReleases": "Toutes les versions", + "release.updateNow": "Mettre à jour", + "release.downloading": "Téléchargement de la mise à jour…", + "release.restartUpdate": "Redémarrer pour mettre à jour", + "release.applying": "Application de la mise à jour…", + "release.updateFailed": "Échec de la mise à jour", + "release.viewDownload": "Voir le téléchargement", + + "failure.title": "Un problème est survenu", + "failure.closeAria": "Fermer la fenêtre d'erreur", + "failure.hint": "Ces détails figureront dans le rapport. Le titre de votre morceau et le lien source ne sont pas inclus - ajoutez-les vous-même s'ils sont utiles. Cliquer sur un bouton ci-dessous copie ceci dans votre presse-papiers.", + "failure.includeLogs": "Inclure les journaux récents", + "failure.collecting": "Collecte des détails…", + "failure.fetchingLogs": "Récupération des journaux…", + "failure.logsIncluded": "Journaux inclus", + "failure.reportGithub": "Signaler sur GitHub", + "failure.reportDiscord": "Signaler sur Discord", + + "settings.title": "Paramètres", + "settings.closeAria": "Fermer les paramètres", + "settings.tab.general": "Général", + "settings.tab.network": "Réseau", + "settings.tab.export": "Export", + "settings.tab.logs": "Journaux", + "settings.tab.registry": "Registre", + "settings.readOnlyServer": "Ces paramètres sont en lecture seule en mode serveur. Pour les modifier, mettez à jour la configuration de votre serveur (par ex. docker-compose.yml) puis redémarrez.", + + "settings.language.title": "Langue", + "settings.language.desc": "Langue d'affichage de l'application.", + + "settings.maxDuration.title": "Durée maximale d'un morceau", + "settings.maxDuration.desc": "Durée maximale acceptée pour le traitement, en minutes (max. 20).", + "settings.playlistLimit.title": "Limite d'import de playlist", + "settings.playlistLimit.desc": "Nombre maximal de morceaux mis en file lors d'un import de playlist (max. 200).", + "settings.stemsLocation.title": "Emplacement des StemData", + "settings.stemsLocation.change": "Modifier…", + "settings.stemsLocation.resetting": "Réinitialisation…", + "settings.stemsLocation.resetFailed": "Échec de la réinitialisation — vérifiez votre connexion.", + "settings.stemsLocation.syncing": "Synchronisation…", + "settings.stemsLocation.syncFailed": "Échec de la synchronisation — vérifiez votre connexion.", + "settings.stemsLocation.inSync": "Tous les morceaux sont synchronisés.", + "settings.stemsLocation.unavailable": "indisponible", + "settings.stemsLocation.persistFailed": "Emplacement mis à jour mais non enregistré — il reviendra au dossier précédent après un redémarrage.", + "settings.stemsLocation.restartNote": "Redémarrez StemDeck pour terminer le changement.", + "settings.stemsLocation.pickerFailed": "Impossible d'ouvrir le sélecteur de dossier.", + "settings.stemsLocation.moving": "Déplacement des pistes… cela peut prendre du temps pour une grande bibliothèque.", + "settings.stemsLocation.moveFailed": "Impossible de déplacer le dossier des pistes.", + "settings.stemsLocation.serverUnreachable": "Impossible de joindre le serveur.", + "settings.stemsLocation.movedPersistFailed.one": "{count} élément déplacé, mais StemDeck n'a pas pu enregistrer ce nouvel emplacement (vérifiez que le dossier est accessible en écriture). Redémarrer maintenant reviendrait à l'ancien emplacement. Essayez de le définir à nouveau.", + "settings.stemsLocation.movedPersistFailed.other": "{count} éléments déplacés, mais StemDeck n'a pas pu enregistrer ce nouvel emplacement (vérifiez que le dossier est accessible en écriture). Redémarrer maintenant reviendrait à l'ancien emplacement. Essayez de le définir à nouveau.", + "settings.stemsLocation.movedOk.one": "{count} élément déplacé. Redémarrez StemDeck pour terminer le changement.", + "settings.stemsLocation.movedOk.other": "{count} éléments déplacés. Redémarrez StemDeck pour terminer le changement.", + + "settings.device.title": "Périphérique de calcul", + "settings.device.desc": "Périphérique utilisé pour la séparation des pistes. S'applique au prochain morceau{resolved}.", + "settings.device.auto": "Auto", + "settings.device.cuda": "CUDA (NVIDIA)", + "settings.device.mps": "MPS (Apple Silicon)", + "settings.device.cpu": "CPU", + "settings.device.currently": " (actuellement : {device})", + "settings.device.notAvailable": " — indisponible", + "settings.quality.title": "Qualité de séparation", + "settings.quality.desc": "« Meilleure » exécute le séparateur deux fois avec des décalages aléatoires et fait la moyenne du résultat — des pistes plus nettes, deux fois plus de temps.", + "settings.quality.standard": "Standard", + "settings.quality.best": "Meilleure (2× plus lent)", + + "settings.outOfSync.subhead": "Morceaux désynchronisés", + "settings.outOfSync.colName": "Nom", + "settings.outOfSync.colSource": "Source", + "settings.outOfSync.colLocation": "Emplacement", + "settings.outOfSync.resync": "Resynchroniser les morceaux désynchronisés", + "settings.outOfSync.allSynced": "Tous les morceaux sont synchronisés", + "settings.outOfSync.summary.one": "{count} morceau est désynchronisé", + "settings.outOfSync.summary.other": "{count} morceaux sont désynchronisés", + "library.importedFile": "Fichier importé", + "library.syncing": "Synchronisation…", + "library.syncFailed": "Échec de la synchronisation — vérifiez votre connexion.", + "library.recent": "Récents", + "library.stemCollections": "Collections de pistes", + "library.tags": "Tags", + "library.newFolder": "Nouveau dossier", + "queue.importQueue": "File d'importation", + "queue.paused.one": "En pause - {count} morceau de votre dernière session.", + "queue.paused.other": "En pause - {count} morceaux de votre dernière session.", + "queue.start": "Démarrer", + "queue.starting": "Démarrage…", + + "track.localFile": "Fichier local", + "track.web": "Web", + "track.losslessWav": "Sans perte (WAV)", + "track.compressedMp3": "Compressé (MP3)", + "track.qualityHigh": "Haute", + "track.untitled": "Morceau sans titre", + "track.unknown": "Morceau inconnu", + "track.removed": "Supprimé", + "track.moveToTrash": "Mettre à la corbeille", + "track.moveTitleToTrash": "Mettre {title} à la corbeille", + "folder.dragToReorder": "Glisser pour réorganiser", + "folder.newSubfolder": "Nouveau sous-dossier", + "folder.deleteFolder": "Supprimer le dossier", + "folder.unsorted": "Non classés", + "favorites.noSearchMatch": "Aucun favori ne correspond à votre recherche", + "favorites.empty": "Aucun favori pour l'instant — cliquez sur ♥ sur un morceau pour l'enregistrer", + "queue.extractNext": "Extraire celui-ci ensuite", + "queue.moveToFront": "Placer {title} en tête de file", + "queue.cancelImport": "Annuler cet import", + "queue.cancelImportOf": "Annuler l'import de {title}", + "queue.pausedStatus": "En pause", + "queue.positionInLine": "En file - n° {position}", + + "settings.exportLogs.title": "Exporter les journaux", + "settings.exportLogs.desc": "Téléchargez tous les fichiers journaux dans une seule archive zip — à joindre à un rapport de bug. Voir l'onglet Journaux pour leur emplacement.", + "settings.exportLogs.button": "Exporter les journaux", + "settings.exportLogs.preparing": "Préparation…", + "settings.exportLogs.error": "Impossible d'exporter les journaux.", + "status.unavailable": "indisponible", + + "settings.resetData.title": "Réinitialiser les données", + "settings.resetData.desc": "Supprime définitivement tous les morceaux, tâches et entrées de la bibliothèque. Sur un serveur partagé, cela affecte tous les utilisateurs. Action irréversible.", + "settings.resetData.button": "Réinitialiser les données…", + + "settings.network.allowTitle": "Rendre StemDeck accessible sur votre réseau", + "settings.network.allowDesc": "Permet à d'autres appareils (comme votre téléphone) d'ouvrir StemDeck à l'adresse ci-dessous.", + "settings.network.lockNote": "En lecture seule lorsque StemDeck est lancé en mode serveur — l'accès réseau est alors défini par la configuration de votre serveur.", + "settings.network.port.title": "Port", + "settings.network.port.desc": "Port utilisé par StemDeck. Redémarrez pour appliquer.", + "settings.network.noConnection": "Aucune connexion au réseau local détectée.", + "settings.network.qrHint": "Flouté pour que votre appareil photo ne s'emballe pas. Touchez pour révéler.", + "settings.network.tapToUnblur": "Touchez pour révéler", + "settings.network.qrCodeFor": "QR code pour {url}", + + "settings.export.sampleRate.title": "Fréquence d'échantillonnage", + "settings.export.sampleRate.desc": "Fréquence d'échantillonnage des mix et régions exportés (WAV, FLAC, MP3). 44,1 kHz convient à la plupart des DAW et samplers ; choisissez-en une autre si votre matériel l'exige.", + "settings.export.videoQuality.title": "Qualité vidéo MP4", + "settings.export.videoQuality.desc": "Résolution maximale pour l'export MP4 et la vidéo YouTube.", + + "settings.logs.locationTab": "Emplacement", + "settings.logs.applicationTab": "Journal de l'application", + "settings.logs.backendTab": "Journal du backend", + "settings.logs.setupTab": "Journal d'installation", + "settings.logs.refresh": "Actualiser", + "settings.logs.loading": "Chargement…", + "settings.logs.location.title": "Emplacement des journaux", + "settings.logs.location.desc": "Où StemDeck écrit ses journaux sur cette machine. Lecture seule — ouvrez-les dans un gestionnaire de fichiers ou utilisez Exporter les journaux.", + "settings.logs.application.title": "Journal de l'application", + "settings.logs.application.desc": "La dernière heure de stemdeck.log — activité du pipeline, de l'API et des tâches. Lecture seule.", + "settings.logs.applicationAria": "Journal de l'application (lecture seule)", + "settings.logs.backend.title": "Journal du backend", + "settings.logs.backend.desc": "La dernière heure de backend.log — sortie brute du processus Python embarqué, y compris ce qui l'a fait planter avant que le journal de l'application ait pu l'enregistrer. Application de bureau uniquement. Lecture seule.", + "settings.logs.backendAria": "Journal du backend (lecture seule)", + "settings.logs.setup.title": "Journal d'installation", + "settings.logs.setup.desc": "La dernière heure de setup.log — configuration initiale et installation du runtime GPU. Application de bureau uniquement. Lecture seule.", + "settings.logs.setupAria": "Journal d'installation (lecture seule)", + + "settings.registry.title": "Registre des tâches", + "settings.registry.desc": "Vue en lecture seule de registry.json — la liste persistante des tâches terminées sur le disque.", + "settings.registry.aria": "Registre des tâches (lecture seule)", + "settings.logs.noFilesYet": "Aucun fichier journal pour l'instant{folderNote}. La journalisation démarre au premier message après le lancement.", + "settings.logs.folderNotCreated": " (le dossier n'a pas été créé)", + "settings.logs.failedToLoad": "Impossible de charger les informations de journalisation.", + + "settings.done": "Terminé", + + "settings.folder.rename": "Renommer le dossier", + "settings.folder.namePlaceholder": "Nom du dossier", + "settings.folder.emptyError": "Saisissez un nom de dossier.", + "settings.folder.charsError": "Utilisez des lettres, chiffres, espaces, ou - _ ' & ( ) . ,", + "settings.folder.cancel": "Annuler", + "settings.folder.save": "Enregistrer", + + "settings.dangerZone": "Zone sensible", + + "folderEditor.title": "Modifier le dossier", + "folderEditor.closeAria": "Fermer", + "folderEditor.nameLabel": "Nom", + "folderEditor.colorLabel": "Couleur", + "folderEditor.colorGroupAria": "Couleur du dossier", + "folderEditor.cancel": "Annuler", + "folderEditor.save": "Enregistrer", + "folderEditor.emptyError": "Saisissez un nom de dossier.", + "folderEditor.tooLong": "Le nom du dossier est trop long (max. {max}).", + "folderEditor.charsError": "Utilisez des lettres, chiffres, espaces, ou - _ ' & ( ) . ,", + + "notifKind.importFailed": "Échec de l'import", + "notifKind.playbackFailed": "Échec de la lecture", + "notifKind.exportFailed": "Échec de l'export", + "notifKind.updateCheckFailed": "Échec de la vérification des mises à jour", + "notifKind.default": "Un problème est survenu", + "notif.somethingWentWrong": "Un problème est survenu.", + "time.justNow": "à l'instant", + "time.minAgo.one": "il y a {count} min", + "time.minAgo.other": "il y a {count} min", + "time.hoursAgo.one": "il y a {count} h", + "time.hoursAgo.other": "il y a {count} h", + + "job.processing": "Traitement", + "job.queuedTrack": "Morceau en file", + "job.process": "Traiter", + "job.tryAgain": "Réessayer", + "job.dismiss": "Ignorer", + "job.peakDb": "Crête {value} dB", + "job.dr.compressed": "Compressée", + "job.dr.moderate": "Modérée", + "job.dr.high": "Élevée", + "job.dr.wide": "Large", + "job.stability.veryStable": "Très stable", + "job.stability.stable": "Stable", + "job.stability.moderate": "Modérée", + "job.stability.variable": "Variable", + "job.bpmValue": "{bpm} BPM", + "job.cancelling": "Annulation…", + "job.processingTrackTitle": "Traitement du morceau", + "job.unknownError": "Erreur inconnue", + "job.noLongerExists": "La tâche n'existe plus sur le serveur", + "job.audioProcessingFailed": "Échec du traitement audio.", + "job.working": "En cours…", + "settings.device.changeFailed": "Impossible de changer le périphérique de calcul.", + "update.checkFailed": "Impossible de vérifier les mises à jour.", + "track.unavailableReimport": "Morceau indisponible - cliquez pour réimporter", + "track.unavailableReupload": "Morceau indisponible - réimportez le fichier pour le restaurer", + "track.audioUnavailableError": "L'audio de ce morceau n'est plus disponible. Réimportez le fichier pour le restaurer.", + "job.restoreFailed": "Échec de la restauration du morceau : {message}", + "job.startFailed": "Échec du démarrage de la tâche : {message}", + "upload.unsupportedFormat": "Seuls les fichiers MP3, WAV, FLAC, MP4, M4A, OGG et Opus sont pris en charge.", + "upload.fileTooLarge": "Fichier trop volumineux ({size}). Le maximum est {max}.", + "upload.skippedFiles.one": "{count} fichier ignoré ({reason}).", + "upload.skippedFiles.other": "{count} fichiers ignorés ({reason}).", + "upload.reasonTooLarge": "trop volumineux ou pas de l'audio", + "upload.reasonNotAudio": "pas de l'audio", + + "player.readyToImport": "Prêt à importer un morceau", + "player.stillLoadingWaveform": "Chargement de la forme d'onde…", + "player.audioCouldNotLoad": "L'audio de ce morceau n'a pas pu être chargé.", + "player.playbackDisabledFull": "La lecture est désactivée pour ce morceau. Les autres morceaux ne sont pas affectés.", + "player.playbackDisabled": "La lecture est désactivée pour ce morceau.", + + "metro.note.full": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie, avec un accent tous les {accent} temps. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.", + "metro.note.noAccent": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.", + "metro.note.detectedSingleBar": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie, avec un accent en {beats}/4 à partir du temps fort détecté. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.", + "metro.note.detectedMultiBar": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie, avec des accents sur {count} régions de mesure détectées. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.", + "metro.note.fallback": "Métronome à {bpm} BPM depuis le détecteur de secours. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.", + + "sections.saving": "Enregistrement", + "sections.saved": "Enregistré", + "sections.deleteAria": "Supprimer la section", + "sections.defaultName": "Section", + + "playlist.skip.unavailable.one": "{count} indisponible", + "playlist.skip.unavailable.other": "{count} indisponibles", + "playlist.skip.tooLong.one": "{count} trop long", + "playlist.skip.tooLong.other": "{count} trop longs", + "playlist.skip.overflow.one": "{count} qui ne tient pas dans la file pour l'instant", + "playlist.skip.overflow.other": "{count} qui ne tiennent pas dans la file pour l'instant", + "playlist.skip.truncated": "tout ce qui dépasse les {cap} premiers", + "playlist.skippingPrefix": "Ignoré : {list}.", + "playlist.listSep": ", ", + "playlist.importAriaLabel": "Importer la playlist", + "playlist.couldNotRead": "Impossible de lire cette playlist : {message}", + "playlist.couldNotImport": "Impossible d'importer cette playlist : {message}", + "playlist.queueFull": "La file est pleine. Attendez qu'elle se vide ou annulez quelque chose d'abord.", + "playlist.nothingImportable": "Rien dans cette playlist ne peut être importé pour l'instant.", + "playlist.confirmTitle": "Importer cette playlist ?", + "playlist.confirmBody.one": "Met en file {count} morceau, un à la fois, dans un dossier du même nom.{skipped}", + "playlist.confirmBody.other": "Met en file {count} morceaux, un à la fois, dans un dossier du même nom.{skipped}", + "playlist.skippedNote": " ({count} indisponible(s) seront ignorés.)", + "playlist.cancel": "Annuler", + "playlist.import.one": "Importer {count} morceau", + "playlist.import.other": "Importer {count} morceaux", + + "resetConfirm.title": "Réinitialiser les données ?", + "resetConfirm.ariaLabel": "Réinitialiser les données", + "resetConfirm.body": "Cela supprime définitivement tous les morceaux, tâches et entrées de la bibliothèque. Sur un serveur partagé, cela affecte tous les utilisateurs. Action irréversible.", + "resetConfirm.hint": "Saisissez RESET pour confirmer.", + "resetConfirm.typeToConfirmAria": "Saisissez RESET pour confirmer", + "resetConfirm.cancel": "Annuler", + "resetConfirm.go": "Réinitialiser les données", + "resetConfirm.resetting": "Réinitialisation…", + "resetConfirm.failed": "Échec de la réinitialisation.", + "resetConfirm.failedConnection": "Échec de la réinitialisation — vérifiez votre connexion.", +}; + +export const TRANSLATIONS = { en, pl, ja, "zh-Hans": zhHans, de, fr, pt, id }; From cc47ce10b93f90cb89d29abedae88b02f0ca2d72 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 23 Aug 2026 20:03:24 +0100 Subject: [PATCH 8/8] chore: pin Unraid template to 0.14.0 Per .claude/rules/unraid-template-version.md this is an explicit decision each time, not a default. Confirmed for this release. The 0.14.0 GHCR image is published by docker-publish.yml when the release is created, so the tag exists shortly after this lands. --- templates/stemdeck.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/stemdeck.xml b/templates/stemdeck.xml index e73de04..d63c6b5 100644 --- a/templates/stemdeck.xml +++ b/templates/stemdeck.xml @@ -1,7 +1,7 @@ StemDeck - ghcr.io/stemdeckapp/stemdeck:0.13.0 + ghcr.io/stemdeckapp/stemdeck:0.14.0 https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck bridge sh