Sfoglia il codice sorgente

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.
maziggy 2 mesi fa
parent
commit
b7ff72d856

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 64 - 0
backend/app/api/routes/updates.py

@@ -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

+ 141 - 0
backend/tests/integration/test_updates_api.py

@@ -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")

+ 0 - 1
frontend/package-lock.json

@@ -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"
       }

+ 29 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -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
frontend/src/api/client.ts

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -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 - 0
frontend/src/i18n/locales/ko.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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"

Some files were not shown because too many files changed in this diff