fix(updates): switch Windows installer installs to release-asset update flow

In-app "Install Update" on Windows installer installs failed with "Could
  not find git executable" because (1) _find_executable's fallback paths
  are Unix-only, and (2) the installer stages backend/ via shutil.copytree
  so there is no .git directory — even with Git for Windows installed, the
  fetch would die on "not a git repository". Adding Windows paths would
  only have changed which error users saw.

  Switches the Windows installer path to a fourth update_method
  ("windows_installer") that mirrors the existing docker / ha_addon
  branches — surface a link to the release .exe and let the user re-run
  the installer, matching the Discord / Spotify Windows update model.

  Backend:
  - New _is_windows_installer_install() — true iff sys.platform == "win32"
    AND no .git in app_dir, so Windows devs with a real git clone keep
    the git path.
  - New _find_windows_installer_asset() picks the matching release asset
    (prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
    to the unversioned alias on non-daily tags).
  - /updates/check now returns is_windows_installer / update_method /
    installer_download_url.
  - /updates/apply short-circuits with a friendly message after the
    existing HA / Docker guards — defense in depth, the frontend swaps
    the button so the POST should not fire on Windows.

  Frontend:
  - UpdateCheckResult extended with the new fields and 'windows_installer'
    in the update_method union.
  - SettingsPage renders a Bambu-green styled <a target="_blank"
    rel="noopener"> between the Docker snippet and the in-app Update
    button, with installer_download_url falling back to release_url then
    the tag page so the link is never broken.
  - applyUpdateMutation onSuccess toast guard extended to treat
    is_windows_installer the same as HA / Docker.
This commit is contained in:
maziggy
2026-06-21 10:25:57 +02:00
parent f8cdaf5f0e
commit 25a23eadd7
20 changed files with 291 additions and 17 deletions
+2
View File
File diff suppressed because one or more lines are too long
+64
View File
@@ -120,6 +120,50 @@ def _is_ha_addon() -> bool:
return bool(os.environ.get("SUPERVISOR_TOKEN"))
def _is_windows_installer_install() -> bool:
"""Detect a Windows install that came from the Inno Setup installer.
The installer stages backend source via ``shutil.copytree`` (no ``.git``
directory) and does not bundle ``git.exe`` — so the git-fetch-and-reset
update path used everywhere else is structurally inoperable here. We
surface this as a distinct ``update_method`` and direct the user at the
release asset instead.
A Windows developer running from a real ``git clone`` keeps the git
path (``.git`` present), so this only catches installer users.
"""
if sys.platform != "win32":
return False
return not (settings.app_dir / ".git").exists()
def _find_windows_installer_asset(release_data: dict) -> str | None:
"""Pick the Windows installer .exe out of a GitHub release's assets list.
Both filenames the workflow uploads end in ``windows-x64-setup.exe``
(versioned ``bambuddy-<version>-windows-x64-setup.exe`` and the
unversioned alias ``bambuddy-windows-x64-setup.exe`` on non-daily tags
only). Either works as a download URL; we prefer the versioned form
because it's the one guaranteed to exist on every release including
dailies.
"""
assets = release_data.get("assets") or []
versioned: str | None = None
unversioned: str | None = None
for asset in assets:
name = asset.get("name") or ""
url = asset.get("browser_download_url")
if not isinstance(name, str) or not isinstance(url, str):
continue
if not name.endswith("windows-x64-setup.exe"):
continue
if name == "bambuddy-windows-x64-setup.exe":
unversioned = url
else:
versioned = url
return versioned or unversioned
def _find_executable(name: str) -> str | None:
"""Find an executable in PATH or common locations."""
# Try standard PATH first
@@ -459,10 +503,15 @@ async def check_for_updates(
is_docker = _is_docker_environment()
is_ha_addon = _is_ha_addon()
is_windows_installer = _is_windows_installer_install()
installer_download_url: str | None = None
if is_ha_addon:
update_method = "ha_addon"
elif is_docker:
update_method = "docker"
elif is_windows_installer:
update_method = "windows_installer"
installer_download_url = _find_windows_installer_asset(release_data)
else:
update_method = "git"
return {
@@ -475,7 +524,9 @@ async def check_for_updates(
"published_at": published_at,
"is_docker": is_docker,
"is_ha_addon": is_ha_addon,
"is_windows_installer": is_windows_installer,
"update_method": update_method,
"installer_download_url": installer_download_url,
}
except httpx.HTTPError as e:
@@ -827,6 +878,19 @@ async def apply_update(
"git pull && docker compose build --pull && docker compose up -d"
),
}
if _is_windows_installer_install():
# The installer layout has no ``.git`` and no bundled ``git.exe`` —
# the git-fetch path would fail. Frontend swaps the "Update now"
# button for a Download Installer link via update_method, so this
# branch is only reached if /apply is hit directly.
return {
"success": False,
"is_windows_installer": True,
"message": (
"Windows installations are updated by re-running the installer. "
"Download the latest installer from the Bambuddy releases page."
),
}
# Discover which release tag to install. Resolved here (where we have
# a DB session) and passed into the background task; the BG task can't
@@ -637,3 +637,144 @@ class TestUpdatesAPI:
assert all(s == f"safe.directory={app_dir}" for s in safe_dir_configs), (
f"safe.directory must point at app_dir ({app_dir}); got {safe_dir_configs}"
)
# --- Windows installer update_method ---
# The Inno-Setup installer stages backend source via ``copytree`` (no
# ``.git``) and does not bundle ``git.exe``. The git-fetch update path
# therefore can't run on those installs — surface a distinct
# ``update_method`` and a release-asset download link instead.
def test_is_windows_installer_install_true_when_no_dot_git(self, tmp_path: Path):
from backend.app.api.routes import updates as updates_module
with (
patch.object(updates_module.sys, "platform", "win32"),
patch.object(updates_module.settings, "app_dir", tmp_path),
):
assert updates_module._is_windows_installer_install() is True
def test_is_windows_installer_install_false_on_dev_checkout(self, tmp_path: Path):
"""A Windows developer with a real ``git clone`` keeps the git path."""
from backend.app.api.routes import updates as updates_module
(tmp_path / ".git").mkdir()
with (
patch.object(updates_module.sys, "platform", "win32"),
patch.object(updates_module.settings, "app_dir", tmp_path),
):
assert updates_module._is_windows_installer_install() is False
def test_is_windows_installer_install_false_off_windows(self, tmp_path: Path):
from backend.app.api.routes import updates as updates_module
with (
patch.object(updates_module.sys, "platform", "linux"),
patch.object(updates_module.settings, "app_dir", tmp_path),
):
assert updates_module._is_windows_installer_install() is False
def test_find_windows_installer_asset_prefers_versioned(self):
from backend.app.api.routes.updates import _find_windows_installer_asset
release = {
"assets": [
{"name": "bambuddy-0.2.5b1-windows-x64-setup.exe", "browser_download_url": "https://x/v.exe"},
{"name": "bambuddy-windows-x64-setup.exe", "browser_download_url": "https://x/alias.exe"},
{"name": "checksums.txt", "browser_download_url": "https://x/c.txt"},
],
}
assert _find_windows_installer_asset(release) == "https://x/v.exe"
def test_find_windows_installer_asset_falls_back_to_alias(self):
from backend.app.api.routes.updates import _find_windows_installer_asset
release = {
"assets": [
{"name": "bambuddy-windows-x64-setup.exe", "browser_download_url": "https://x/alias.exe"},
],
}
assert _find_windows_installer_asset(release) == "https://x/alias.exe"
def test_find_windows_installer_asset_none_when_missing(self):
from backend.app.api.routes.updates import _find_windows_installer_asset
assert _find_windows_installer_asset({"assets": []}) is None
assert _find_windows_installer_asset({}) is None
@pytest.mark.asyncio
async def test_apply_update_windows_installer_rejection(self, async_client: AsyncClient):
"""Direct POST /apply on a Windows-installer install must be rejected
with a friendly message — the git path would error out with "git not
found" (or worse, "not a git repository") if it ran."""
with (
patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
patch(
"backend.app.api.routes.updates._is_windows_installer_install",
return_value=True,
),
):
response = await async_client.post("/api/v1/updates/apply")
result = response.json()
assert result["success"] is False
assert result["is_windows_installer"] is True
assert "installer" in result["message"].lower()
@pytest.mark.asyncio
async def test_check_windows_installer_returns_method_and_url(self, async_client: AsyncClient):
"""/updates/check must surface update_method=windows_installer plus
the installer .exe URL so the frontend can render a Download button
instead of the in-app Install button."""
import httpx as _httpx
fake_release = {
# Non-prerelease tag — beta-channel filter defaults to off, so a
# `b1` suffix would be skipped and the route would return
# "No releases found" before reaching update_method.
"tag_name": "v999.9.9",
"name": "v999.9.9",
"body": "",
"html_url": "https://github.com/maziggy/bambuddy/releases/tag/v999.9.9",
"published_at": "2099-01-01T00:00:00Z",
"assets": [
{
"name": "bambuddy-999.9.9-windows-x64-setup.exe",
"browser_download_url": "https://github.com/maziggy/bambuddy/releases/download/v999.9.9/bambuddy-999.9.9-windows-x64-setup.exe",
},
],
}
class _Resp:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return [fake_release]
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return None
async def get(self, *_, **__):
return _Resp()
with (
patch.object(_httpx, "AsyncClient", _FakeClient),
patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
patch(
"backend.app.api.routes.updates._is_windows_installer_install",
return_value=True,
),
):
response = await async_client.get("/api/v1/updates/check")
body = response.json()
assert "update_method" in body, f"unexpected response shape: {body}"
assert body["update_method"] == "windows_installer"
assert body["is_windows_installer"] is True
assert body["installer_download_url"].endswith("bambuddy-999.9.9-windows-x64-setup.exe")
-1
View File
@@ -6401,7 +6401,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -488,6 +488,35 @@ describe('SettingsPage', () => {
expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
});
it('shows the installer-download link for Windows installer installs', async () => {
const downloadUrl =
'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';
await renderWithUpdateCheck({
update_available: true,
current_version: '0.2.4',
latest_version: '0.2.5',
release_name: '0.2.5',
release_notes: '',
release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5',
published_at: '2099-01-01T00:00:00Z',
is_docker: false,
is_ha_addon: false,
is_windows_installer: true,
update_method: 'windows_installer',
installer_download_url: downloadUrl,
});
const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i });
expect(link).toHaveAttribute('href', downloadUrl);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
// The in-app update button must NOT render — the git-fetch path can't
// work from an installer payload.
expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
});
});
describe('tabs navigation', () => {
+4 -2
View File
@@ -2825,7 +2825,9 @@ export interface UpdateCheckResult {
message?: string;
is_docker?: boolean;
is_ha_addon?: boolean;
update_method?: 'docker' | 'git' | 'ha_addon';
is_windows_installer?: boolean;
update_method?: 'docker' | 'git' | 'ha_addon' | 'windows_installer';
installer_download_url?: string | null;
}
export interface UpdateStatus {
@@ -5334,7 +5336,7 @@ export const api = {
getVersion: () => request<VersionInfo>('/updates/version'),
checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
applyUpdate: () =>
request<{ success: boolean; message: string; status?: UpdateStatus; is_docker?: boolean; is_ha_addon?: boolean }>('/updates/apply', {
request<{ success: boolean; message: string; status?: UpdateStatus; is_docker?: boolean; is_ha_addon?: boolean; is_windows_installer?: boolean }>('/updates/apply', {
method: 'POST',
}),
getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
+2
View File
@@ -2294,6 +2294,8 @@ export default {
releaseNotes: 'Versionshinweise',
updateViaDocker: 'Update über Docker Compose:',
updateViaHomeAssistant: 'Updates werden vom Home Assistant Supervisor verwaltet. Öffne Einstellungen → Add-ons → Bambuddy in Home Assistant, um die neue Version zu installieren.',
updateViaWindowsInstaller: 'Windows-Installationen werden durch erneutes Ausführen des Installers aktualisiert. Lade die neue Version unten herunter — deine Daten, Einstellungen und Drucker bleiben erhalten.',
downloadWindowsInstaller: 'Installer für v{{version}} herunterladen',
installUpdate: 'Update installieren',
latestVersionRunning: 'Sie verwenden die neueste Version',
failedToCheckUpdates: 'Update-Prüfung fehlgeschlagen: {{error}}',
+2
View File
@@ -2305,6 +2305,8 @@ export default {
releaseNotes: 'Release Notes',
updateViaDocker: 'Update via Docker Compose:',
updateViaHomeAssistant: 'Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version.',
updateViaWindowsInstaller: 'Windows installations are updated by re-running the installer. Download the new version below — your data, settings and printers are preserved.',
downloadWindowsInstaller: 'Download installer for v{{version}}',
installUpdate: 'Install Update',
latestVersionRunning: "You're running the latest version",
failedToCheckUpdates: 'Failed to check for updates: {{error}}',
+2
View File
@@ -2297,6 +2297,8 @@ export default {
releaseNotes: 'Notas de la versión',
updateViaDocker: 'Actualizar mediante Docker Compose:',
updateViaHomeAssistant: 'Las actualizaciones las gestiona el Supervisor de Home Assistant. Abra Ajustes → Complementos → Bambuddy en Home Assistant para instalar la nueva versión.',
updateViaWindowsInstaller: 'Las instalaciones en Windows se actualizan volviendo a ejecutar el instalador. Descarga la nueva versión abajo — tus datos, ajustes e impresoras se conservan.',
downloadWindowsInstaller: 'Descargar instalador para v{{version}}',
installUpdate: 'Instalar actualización',
latestVersionRunning: 'Está ejecutando la versión más reciente',
failedToCheckUpdates: 'Error al buscar actualizaciones: {{error}}',
+2
View File
@@ -2244,6 +2244,8 @@ export default {
releaseNotes: 'Notes de version',
updateViaDocker: 'Mettre à jour via Docker Compose :',
updateViaHomeAssistant: 'Les mises à jour sont gérées par le superviseur Home Assistant. Ouvrez Paramètres → Modules complémentaires → Bambuddy dans Home Assistant pour installer la nouvelle version.',
updateViaWindowsInstaller: "Les installations Windows se mettent à jour en relançant l'installateur. Téléchargez la nouvelle version ci-dessous — vos données, paramètres et imprimantes sont préservés.",
downloadWindowsInstaller: "Télécharger l'installateur pour la v{{version}}",
installUpdate: 'Installer la mise à jour',
latestVersionRunning: 'Vous utilisez la dernière version',
failedToCheckUpdates: 'Échec de la vérification des mises à jour : {{error}}',
+2
View File
@@ -2243,6 +2243,8 @@ export default {
releaseNotes: 'Note di rilascio',
updateViaDocker: 'Aggiorna tramite Docker Compose:',
updateViaHomeAssistant: 'Gli aggiornamenti sono gestiti dal Supervisor di Home Assistant. Apri Impostazioni → Add-on → Bambuddy in Home Assistant per installare la nuova versione.',
updateViaWindowsInstaller: 'Le installazioni Windows si aggiornano rieseguendo l\'installer. Scarica la nuova versione qui sotto — i tuoi dati, le impostazioni e le stampanti vengono mantenuti.',
downloadWindowsInstaller: 'Scarica installer per v{{version}}',
installUpdate: 'Installa aggiornamento',
latestVersionRunning: 'Stai usando l\'ultima versione',
failedToCheckUpdates: 'Controllo aggiornamenti fallito: {{error}}',
+2
View File
@@ -2293,6 +2293,8 @@ export default {
releaseNotes: 'リリースノート',
updateViaDocker: 'Docker Composeでアップデート:',
updateViaHomeAssistant: 'アップデートはHome Assistant Supervisorによって管理されます。Home Assistantの設定→アドオン→Bambuddyを開いて新しいバージョンをインストールしてください。',
updateViaWindowsInstaller: 'Windowsインストールはインストーラーを再実行して更新します。下のリンクから新しいバージョンをダウンロードしてください — データ、設定、プリンターは保持されます。',
downloadWindowsInstaller: 'v{{version}} のインストーラーをダウンロード',
installUpdate: 'アップデートをインストール',
latestVersionRunning: '最新バージョンを使用しています',
failedToCheckUpdates: 'アップデートの確認に失敗しました: {{error}}',
+2
View File
@@ -2160,6 +2160,8 @@ export default {
releaseNotes: '릴리스 노트',
updateViaDocker: 'Docker Compose로 업데이트:',
updateViaHomeAssistant: '업데이트는 Home Assistant 수퍼바이저에서 관리됩니다. Home Assistant에서 설정 → 애드온 → Bambuddy를 열어 새 버전을 설치하세요.',
updateViaWindowsInstaller: 'Windows 설치본은 설치 프로그램을 다시 실행하여 업데이트합니다. 아래에서 새 버전을 다운로드하세요 — 데이터, 설정 및 프린터는 유지됩니다.',
downloadWindowsInstaller: 'v{{version}} 설치 프로그램 다운로드',
installUpdate: '업데이트 설치',
latestVersionRunning: '최신 버전을 실행 중입니다',
failedToCheckUpdates: '업데이트 확인 실패: {{error}}',
+2
View File
@@ -2243,6 +2243,8 @@ export default {
releaseNotes: 'Notas da versão',
updateViaDocker: 'Atualizar via Docker Compose:',
updateViaHomeAssistant: 'As atualizações são gerenciadas pelo Supervisor do Home Assistant. Abra Configurações → Complementos → Bambuddy no Home Assistant para instalar a nova versão.',
updateViaWindowsInstaller: 'Instalações no Windows são atualizadas executando o instalador novamente. Baixe a nova versão abaixo — seus dados, configurações e impressoras são preservados.',
downloadWindowsInstaller: 'Baixar instalador da v{{version}}',
installUpdate: 'Instalar atualização',
latestVersionRunning: 'Você está usando a versão mais recente',
failedToCheckUpdates: 'Falha ao verificar atualizações: {{error}}',
+2
View File
@@ -2297,6 +2297,8 @@ export default {
releaseNotes: 'Sürüm Notları',
updateViaDocker: 'Docker Compose ile güncelle:',
updateViaHomeAssistant: "Güncellemeler Home Assistant Supervisor tarafından yönetilir. Yeni sürümü yüklemek için Home Assistant'ta Ayarlar → Eklentiler → Bambuddy'ye gidin.",
updateViaWindowsInstaller: 'Windows kurulumları, kurucu yeniden çalıştırılarak güncellenir. Yeni sürümü aşağıdan indirin — verileriniz, ayarlarınız ve yazıcılarınız korunur.',
downloadWindowsInstaller: 'v{{version}} için kurucuyu indir',
installUpdate: 'Güncellemeyi Yükle',
latestVersionRunning: 'En son sürümü çalıştırıyorsunuz',
failedToCheckUpdates: 'Güncellemeler kontrol edilemedi: {{error}}',
+2
View File
@@ -2288,6 +2288,8 @@ export default {
releaseNotes: '发布说明',
updateViaDocker: '通过 Docker Compose 更新:',
updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。请在 Home Assistant 中打开 设置 → 加载项 → Bambuddy 以安装新版本。',
updateViaWindowsInstaller: 'Windows 安装通过重新运行安装程序来更新。请在下方下载新版本 — 您的数据、设置和打印机都会保留。',
downloadWindowsInstaller: '下载 v{{version}} 安装程序',
installUpdate: '安装更新',
latestVersionRunning: '您正在运行最新版本',
failedToCheckUpdates: '检查更新失败:{{error}}',
+2
View File
@@ -2288,6 +2288,8 @@ export default {
releaseNotes: '發布說明',
updateViaDocker: '透過 Docker Compose 更新:',
updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。請在 Home Assistant 中開啟 設定 → 附加元件 → Bambuddy 以安裝新版本。',
updateViaWindowsInstaller: 'Windows 安裝可透過重新執行安裝程式來更新。請在下方下載新版本 — 您的資料、設定和印表機都會保留。',
downloadWindowsInstaller: '下載 v{{version}} 安裝程式',
installUpdate: '安裝更新',
latestVersionRunning: '您正在執行最新版本',
failedToCheckUpdates: '檢查更新失敗:{{error}}',
+16 -1
View File
@@ -768,7 +768,7 @@ export function SettingsPage() {
const applyUpdateMutation = useMutation({
mutationFn: api.applyUpdate,
onSuccess: (data) => {
if (data.is_ha_addon || data.is_docker) {
if (data.is_ha_addon || data.is_docker || data.is_windows_installer) {
showToast(data.message, 'error');
} else {
refetchUpdateStatus();
@@ -2549,6 +2549,21 @@ export function SettingsPage() {
docker compose pull && docker compose up -d
</code>
</div>
) : updateCheck?.update_method === 'windows_installer' ? (
<div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
<p className="text-sm text-bambu-gray mb-3">
{t('settings.updateViaWindowsInstaller')}
</p>
<a
href={updateCheck.installer_download_url || updateCheck.release_url || `https://github.com/maziggy/bambuddy/releases/tag/v${updateCheck.latest_version}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0"
>
<Download className="w-4 h-4" />
{t('settings.downloadWindowsInstaller', { version: updateCheck.latest_version })}
</a>
</div>
) : (
<Button
className="mt-3"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BdzWqWAS.js"></script>
<script type="module" crossorigin src="/assets/index-Ckvy6dRm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7mua31Zk.css">
</head>
<body>