diff --git a/static/css/daw.css b/static/css/daw.css
index 64695e49..8370923a 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -3728,6 +3728,7 @@ input, textarea { font-family: inherit; }
}
.release-inapp-progress-text { margin: 0; font-size: 11px; color: var(--muted); }
.release-inapp-error { margin: 0; font-size: 12px; color: #e54e4e; }
+.release-full-download { margin: 0 0 12px; font-size: 13px; line-height: 1.45; text-align: left; color: var(--fg); }
/* 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
@@ -3738,6 +3739,7 @@ input, textarea { font-family: inherit; }
.release-inapp.hidden,
.release-inapp-progress.hidden,
.release-inapp-error.hidden,
+.release-full-download.hidden,
.release-card .about-link.hidden {
display: none !important;
}
diff --git a/static/index.html b/static/index.html
index 517587e0..6c353e35 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1204,6 +1204,7 @@
New release available
+
This version is a full download rather than an update: press Download, unzip it, and run it. Your library stays as it is.
Download
diff --git a/static/js/catalog.js b/static/js/catalog.js
index 61c896f5..bf2c0c70 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -2942,6 +2942,12 @@ function updaterAssetNames(target) {
// 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.
+// check_app_update's reason when the release needs Python packages this
+// install's runtime does not have (main.rs). The download is then a full
+// package to unzip and run rather than an update, and the dialog says so
+// (#720): without it, a missing "Update now" read as a broken update.
+const RUNTIME_CHANGED = /^python dependencies changed\b/;
+
async function resolveInAppUpdatePlan(release, target) {
const names = updaterAssetNames(target);
if (!names) return null;
@@ -2958,6 +2964,8 @@ async function resolveInAppUpdatePlan(release, target) {
});
if (!check?.supported) {
console.info("[catalog] in-app update unavailable:", check?.reason || "unknown");
+ document.getElementById("releaseFullDownload")
+ ?.classList.toggle("hidden", !RUNTIME_CHANGED.test(String(check?.reason || "")));
return null;
}
@@ -3053,6 +3061,8 @@ async function openReleaseDialog() {
const dockerCmd = document.getElementById("releaseDockerCmd");
if (version) version.textContent = `v${normalizeVersion(latestRelease.tag_name)}`;
+ // Shown again only when this release is found to need a full download.
+ document.getElementById("releaseFullDownload")?.classList.add("hidden");
if (notes) {
const body = (latestRelease.body || "").trim();
notes.innerHTML = body
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 6900054a..35f8785b 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -669,6 +669,7 @@ const en = {
"release.restartUpdate": "Restart to update",
"release.applying": "Applying update…",
"release.updateFailed": "Update failed",
+ "release.fullDownload": "This version is a full download rather than an update: press Download, unzip it, and run it. Your library stays as it is.",
"failure.title": "Something failed",
"failure.closeAria": "Close failure dialog",
@@ -1464,6 +1465,7 @@ const pl = {
"release.restartUpdate": "Uruchom ponownie, aby zaktualizować",
"release.applying": "Instalowanie aktualizacji…",
"release.updateFailed": "Aktualizacja nie powiodła się",
+ "release.fullDownload": "Ta wersja to pełne pobranie, a nie aktualizacja: kliknij Pobierz, rozpakuj plik i uruchom go. Twoja biblioteka zostaje bez zmian.",
"failure.title": "Coś poszło nie tak",
"failure.closeAria": "Zamknij okno błędu",
@@ -2250,6 +2252,7 @@ const ja = {
"release.restartUpdate": "再起動して更新",
"release.applying": "アップデートを適用中…",
"release.updateFailed": "アップデートに失敗しました",
+ "release.fullDownload": "このバージョンはアップデートではなく、フルダウンロードです。「ダウンロード」を押して展開し、実行してください。ライブラリはそのまま残ります。",
"failure.title": "エラーが発生しました",
"failure.closeAria": "エラーダイアログを閉じる",
@@ -3012,6 +3015,7 @@ const zhHans = {
"release.restartUpdate": "重启以更新",
"release.applying": "正在应用更新…",
"release.updateFailed": "更新失败",
+ "release.fullDownload": "此版本需要完整下载,而不是更新:点击“下载”,解压后运行即可。你的曲库保持不变。",
"failure.title": "出现故障",
"failure.closeAria": "关闭故障对话框",
@@ -3775,6 +3779,7 @@ const de = {
"release.restartUpdate": "Zum Aktualisieren neu starten",
"release.applying": "Update wird angewendet…",
"release.updateFailed": "Update fehlgeschlagen",
+ "release.fullDownload": "Diese Version ist ein vollständiger Download statt eines Updates: Klicke auf Herunterladen, entpacke die Datei und starte StemDeck. Deine Bibliothek bleibt, wie sie ist.",
"failure.title": "Etwas ist fehlgeschlagen",
"failure.closeAria": "Fehlerdialog schließen",
@@ -4548,6 +4553,7 @@ const pt = {
"release.restartUpdate": "Reiniciar para atualizar",
"release.applying": "Aplicando atualização…",
"release.updateFailed": "Falha na atualização",
+ "release.fullDownload": "Esta versão é um download completo, não uma atualização: clique em Baixar, descompacte e execute. Sua biblioteca continua como está.",
"failure.title": "Algo falhou",
"failure.closeAria": "Fechar diálogo de falha",
@@ -5322,6 +5328,7 @@ const id = {
"release.restartUpdate": "Mulai ulang untuk memperbarui",
"release.applying": "Menerapkan pembaruan…",
"release.updateFailed": "Pembaruan gagal",
+ "release.fullDownload": "Versi ini berupa unduhan lengkap, bukan pembaruan: tekan Unduh, ekstrak, lalu jalankan. Pustaka Anda tetap seperti semula.",
"failure.title": "Terjadi kesalahan",
"failure.closeAria": "Tutup dialog kesalahan",
@@ -6085,6 +6092,7 @@ const fr = {
"release.restartUpdate": "Redémarrer pour mettre à jour",
"release.applying": "Application de la mise à jour…",
"release.updateFailed": "Échec de la mise à jour",
+ "release.fullDownload": "Cette version est un téléchargement complet plutôt qu'une mise à jour : cliquez sur Télécharger, décompressez-la et lancez-la. Votre bibliothèque reste telle quelle.",
"release.viewDownload": "Voir le téléchargement",
"failure.title": "Un problème est survenu",
@@ -6543,6 +6551,7 @@ const ptPT = {
"sections.clearAria": "Limpar todas as secções",
"aria.download": "Transferir {name}",
"release.download": "Transferir",
+ "release.fullDownload": "Esta versão é uma transferência completa, não uma atualização: clique em Transferir, descomprima e execute. A sua biblioteca fica como está.",
"release.downloading": "A transferir atualização…",
"release.pullImage": "Transfira a nova imagem e reinicie o contentor:",
"settings.exportLogs.desc": "Transfira todos os ficheiros de log num único zip, o que anexar a um relatório de erro. Veja o separador Logs para saber onde ficam.",
@@ -7029,6 +7038,7 @@ const es = {
"release.restartUpdate": "Reinicia para actualizar",
"release.applying": "Aplicando la actualización…",
"release.updateFailed": "La actualización falló",
+ "release.fullDownload": "Esta versión es una descarga completa, no una actualización: pulsa Descargar, descomprímela y ejecútala. Tu biblioteca se queda como está.",
"failure.title": "Algo falló",
"failure.closeAria": "Cerrar el diálogo de error",
@@ -7824,6 +7834,7 @@ const ko = {
"release.restartUpdate": "다시 시작해서 업데이트",
"release.applying": "업데이트 적용 중…",
"release.updateFailed": "업데이트에 실패했어요",
+ "release.fullDownload": "이번 버전은 업데이트가 아니라 전체 다운로드예요. 다운로드를 누르고 압축을 푼 다음 실행하세요. 라이브러리는 그대로 유지돼요.",
"failure.title": "무언가 실패했어요",
"failure.closeAria": "오류 창 닫기",
diff --git a/tests/e2e/release-full-download.spec.mjs b/tests/e2e/release-full-download.spec.mjs
new file mode 100644
index 00000000..451774ad
--- /dev/null
+++ b/tests/e2e/release-full-download.spec.mjs
@@ -0,0 +1,81 @@
+// The update dialog when a release cannot be applied in place (#720).
+//
+// A release that changes the Python dependencies cannot be applied by the
+// one-click update, which keeps the installed runtime. The dialog then offers
+// only Download, and has to say that this download is a full package rather
+// than an update, or a missing "Update now" reads as a broken update.
+import { expect, test } from "@playwright/test";
+import { stubTauri } from "./helpers.mjs";
+
+const ASSETS = [
+ "StemDeck-Windows-x64.NVIDIA.zip",
+ "StemDeck-Windows-x64-app.zip",
+ "StemDeck-Windows-x64-app.zip.sha256",
+ "StemDeck-Windows-x64-runtime-version.json",
+].map((name) => ({ name, browser_download_url: `https://example.invalid/${name}` }));
+
+async function installedAppOffered(page, check) {
+ await stubTauri(page);
+ // After stubTauri's script: a Windows NVIDIA install, and the updater's
+ // answer for this release.
+ await page.addInitScript((answer) => {
+ const invoke = window.__TAURI__.core.invoke;
+ window.__TAURI__.core.invoke = (cmd, args) => {
+ if (cmd === "build_target") return Promise.resolve({ os: "windows", arch: "x64", gpu: "nvidia" });
+ if (cmd === "check_app_update") return Promise.resolve(answer);
+ return invoke(cmd, args);
+ };
+ }, check);
+ await page.route("**/api/health**", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ name: "StemDeck", status: "ok", version: "0.18.1", ffmpeg_configured: true }),
+ }));
+ await page.route("https://api.github.com/**", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ tag_name: "v0.19.0",
+ draft: false,
+ prerelease: false,
+ body: "notes",
+ html_url: "https://example.invalid",
+ assets: ASSETS,
+ }),
+ }));
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator("#notifBtn").click();
+ await page.locator("#notifReleaseCard").click();
+ await expect(page.locator("#releaseDialog")).toBeVisible();
+}
+
+test.describe("update dialog", () => {
+ test("a release that needs a new runtime says it is a full download", async ({ page }) => {
+ await installedAppOffered(page, {
+ supported: false,
+ reason: "python dependencies changed (py3.12-aaaaaaaaaaaaaaaa -> py3.12-bbbbbbbbbbbbbbbb)",
+ });
+ const note = page.locator("#releaseFullDownload");
+ await expect(note).toBeVisible();
+ await expect(note).toContainText("full download");
+ await expect(page.locator("#releaseDownloadApp")).toBeHidden();
+ await expect(page.locator("#releaseDownload")).toHaveAttribute(
+ "href",
+ "https://example.invalid/StemDeck-Windows-x64.NVIDIA.zip",
+ );
+ });
+
+ test("any other reason for no in-app update says nothing about a full download", async ({ page }) => {
+ await installedAppOffered(page, { supported: false, reason: "this install is not writable by the current user" });
+ await expect(page.locator("#releaseDownload")).toBeVisible();
+ await expect(page.locator("#releaseFullDownload")).toBeHidden();
+ });
+
+ test("a release that updates in place offers Update now and no note", async ({ page }) => {
+ await installedAppOffered(page, { supported: true, appSha256: "0".repeat(64) });
+ await expect(page.locator("#releaseDownloadApp")).toBeVisible();
+ await expect(page.locator("#releaseFullDownload")).toBeHidden();
+ });
+});