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/.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/.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/app/main.py b/app/main.py index a584472..7c2043b 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,32 @@ 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 (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. try: return package_version("stemdeck") except PackageNotFoundError: diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 16fd845..3b7d36a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -19,6 +19,30 @@ use tauri_plugin_store::StoreExt; use zip::ZipArchive; const SETUP_VERSION: u64 = 1; + +// ── 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 @@ -191,6 +215,58 @@ 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 +/// 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 +/// 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, +} + +/// 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 { @@ -250,6 +326,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"); @@ -310,6 +401,10 @@ fn main() { download_runtime_pack, verify_runtime_pack, extract_runtime_pack, + installed_runtime_id, + check_app_update, + download_app_update, + apply_app_update, ensure_external_assets, ensure_torch_device, warmup_models, @@ -399,7 +494,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")) { @@ -735,6 +831,459 @@ 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(format!("{APP_EXE_NAME}.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 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 +/// 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()) +} + +/// Decides whether the latest release can be applied in place, and resolves its +/// 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/` +/// (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(any(windows, target_os = "linux")))] + { + let _ = query; + Ok(AppUpdateAvailability { + supported: false, + reason: Some("in-app updates are not available on this platform".to_string()), + ..Default::default() + }) + } + #[cfg(any(windows, target_os = "linux"))] + { + let unsupported = |reason: &str| { + Ok(AppUpdateAvailability { + supported: false, + reason: Some(reason.to_string()), + ..Default::default() + }) + }; + + // A root-owned install (Linux `install.sh --global` puts it in + // /opt/stemdeck) cannot rewrite itself. Check before promising an + // update we would fail to apply. + match app_root() { + Ok(root) if !app_root_is_writable(&root) => { + 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"); + }; + 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(any(windows, target_os = "linux"))] +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, 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 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(any(windows, target_os = "linux")))] + { + let _ = (plan, app_handle); + Err("in-app updates are not available on this platform".to_string()) + } + #[cfg(any(windows, target_os = "linux"))] + { + 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(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()) { + 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(()) +} + +/// 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 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. 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(), + Err(_) => return Err("backend state is unavailable".to_string()), + }; + 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 { + 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(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; + 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 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. +#[tauri::command] +fn apply_app_update( + state: tauri::State, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + #[cfg(not(any(windows, target_os = "linux")))] + { + let _ = (state, app_handle); + Err("in-app updates are not available on this platform".to_string()) + } + #[cfg(any(windows, target_os = "linux"))] + { + 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_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(format!( + "app update archive did not contain {APP_EXE_NAME} and backend/app" + )); + } + 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(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()))?; + } + 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 +4365,93 @@ 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] + 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:?}"); + } + } + + #[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")] @@ -4056,7 +4692,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/linux/make-portable.sh b/scripts/linux/make-portable.sh index 2400b62..550db22 100755 --- a/scripts/linux/make-portable.sh +++ b/scripts/linux/make-portable.sh @@ -183,6 +183,47 @@ 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. +# 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}" + +# 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 +247,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/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 6aa3587..4a07103 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" @@ -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 @@ -229,20 +235,88 @@ 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() +# 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) +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 { @@ -273,8 +347,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..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; } @@ -2786,12 +2795,39 @@ 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; +} +/* 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; } + /* 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..edc8832 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..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 @@ -2266,6 +2273,153 @@ function pickReleaseAsset(release, target) { return asset ? { url: asset.browser_download_url, name } : null; } +// ─── 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 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 +// 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; +} + +// 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 +// 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 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", { + 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: check.appSha256 }; +} + +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 wireInAppUpdate(target) { + const downloadBtn = document.getElementById("releaseDownloadApp"); + const applyBtn = document.getElementById("releaseApplyUpdate"); + const inapp = document.getElementById("releaseInapp"); + const progress = document.getElementById("releaseInappProgress"); + const progressText = document.getElementById("releaseInappProgressText"); + const errorEl = document.getElementById("releaseInappError"); + if (!downloadBtn || !applyBtn || !latestRelease) return false; + + const plan = await resolveInAppUpdatePlan(latestRelease, target); + if (!plan) return false; + + // 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"); + downloadBtn.disabled = false; + applyBtn.disabled = false; + 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 (progressText) progressText.textContent = i18nT("release.downloading"); + progress?.classList.remove("hidden"); + progress?.classList.add("indeterminate"); + try { + 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"); + } + }; + + 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,6 +2449,10 @@ async function openReleaseDialog() { } else if (download) { 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; @@ -2306,6 +2464,20 @@ async function openReleaseDialog() { download.textContent = i18nT("release.viewDownload"); } download.classList.remove("hidden"); + + let usedInapp = false; + if (updaterAssetNames(target)) { + try { + usedInapp = await wireInAppUpdate(target); + } 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"); + } } dialog.classList.remove("hidden"); @@ -2332,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. diff --git a/static/js/i18n.js b/static/js/i18n.js index 52be409..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" }, ]; @@ -386,6 +387,11 @@ const en = { "release.unraidNote": "On Unraid, update via the Community Applications template instead.", "release.download": "Download", "release.allReleases": "All releases", + "release.updateNow": "Update now", + "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 +891,11 @@ const pl = { "release.unraidNote": "Na Unraid zaktualizuj przez szablon Community Applications.", "release.download": "Pobierz", "release.allReleases": "Wszystkie wersje", + "release.updateNow": "Zaktualizuj teraz", + "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 +1386,11 @@ const ja = { "release.unraidNote": "Unraidの場合は、Community Applicationsテンプレート経由で更新してください。", "release.download": "ダウンロード", "release.allReleases": "すべてのリリース", + "release.updateNow": "今すぐ更新", + "release.downloading": "アップデートをダウンロード中…", + "release.restartUpdate": "再起動して更新", + "release.applying": "アップデートを適用中…", + "release.updateFailed": "アップデートに失敗しました", "failure.title": "エラーが発生しました", "failure.closeAria": "エラーダイアログを閉じる", @@ -1841,6 +1857,11 @@ const zhHans = { "release.unraidNote": "在 Unraid 上,请通过 Community Applications 模板更新。", "release.download": "下载", "release.allReleases": "所有版本", + "release.updateNow": "立即更新", + "release.downloading": "正在下载更新…", + "release.restartUpdate": "重启以更新", + "release.applying": "正在应用更新…", + "release.updateFailed": "更新失败", "failure.title": "出现故障", "failure.closeAria": "关闭故障对话框", @@ -2308,6 +2329,11 @@ const de = { "release.unraidNote": "Bei Unraid stattdessen über die Community-Applications-Vorlage aktualisieren.", "release.download": "Herunterladen", "release.allReleases": "Alle Versionen", + "release.updateNow": "Jetzt aktualisieren", + "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 +2811,11 @@ const pt = { "release.unraidNote": "No Unraid, atualize pelo modelo do Community Applications.", "release.download": "Baixar", "release.allReleases": "Todas as versões", + "release.updateNow": "Atualizar agora", + "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 +3294,11 @@ const id = { "release.unraidNote": "Di Unraid, perbarui melalui template Community Applications.", "release.download": "Unduh", "release.allReleases": "Semua versi", + "release.updateNow": "Perbarui sekarang", + "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", @@ -3526,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 }; 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 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; } diff --git a/tests/test_health_api.py b/tests/test_health_api.py index fd38368..52eb34c 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -17,3 +17,44 @@ 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): + 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") + 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. + 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") + 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. + from app import main + + monkeypatch.setattr(main, "STATIC_DIR", tmp_path) + assert main.app_version() == main.package_version("stemdeck") + + # "[]" 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