diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d38a7a5d..6e9c81ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ All notable changes to Bambuddy will be documented in this file. - **Push notification for "Printer offline" now actually fires (#1752, reported by @saint-hh)** — The notification provider's `on_printer_offline` toggle has shipped since the notifications feature landed: schema field, DB column, `notification_template.py` entry, and the dispatcher `NotificationService.on_printer_offline(printer_id, printer_name, db)` are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware's `gcode_state=FAILED` report on MQTT reconnect. **Why the toggle was orphan:** every other provider event (`on_print_start`, `on_print_complete`, `on_print_progress`, `on_printer_error`, etc.) has a clear call site under `main.py::on_printer_status_change` or alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (`smart_plug_manager` → `printer_manager.mark_printer_offline()` and `bambu_mqtt.py::check_staleness` after the 30s STALE_RECONNECT_COOLDOWN) route through `_on_status_change` already and reach `on_printer_status_change`; the handler just didn't act on the disconnect edge. **Fix:** edge detection in `on_printer_status_change` watches `state.connected` against the previous observation per printer (`_printer_last_connected: dict[int, bool]`). On the True → False transition it schedules `_maybe_notify_printer_offline(printer_id)` as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps `_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0` then re-checks `printer_manager.is_connected(printer_id)` — only fires the notification if the printer is still offline. **Why 60s debounce:** sized against `bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s` — a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. **Edge-case handling:** initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from `_printer_offline_notify_tasks` in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. **No symmetric `on_printer_online` event:** the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reporting `gcode_state=FAILED` for the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy's `bambu_mqtt.py:3039` explicitly suppresses `is_new_print` for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. **Tests:** 9 new cases in `test_printer_offline_notification.py` split across two classes. `TestMaybeNotifyPrinterOffline` pins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears `_printer_offline_notify_tasks[printer_id]` after run. `TestOfflineEdgeDetection` pins the edge logic inside `on_printer_status_change`: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean. +- **In-app "Install Update" on Windows installer installs failed with "Could not find git executable"** — Reported off-list by a Windows user running the `.exe` installer. Root cause has two layers, and the surfaced error only described the first. **Layer 1 — git not findable on Windows.** `backend/app/api/routes/updates.py::_find_executable("git")` falls through `shutil.which("git")` (the installer doesn't bundle git, fresh Windows installs have no git on PATH) into a fallback list of *Unix-only* paths (`/usr/bin/git`, `/opt/homebrew/bin/git`, `~/.local/bin/git` etc) — none exist on Windows, so the helper returns `None` and the route reports "Could not find git executable. Please ensure git is installed." **Layer 2 — no `.git` directory in the installer payload anyway.** `installers/windows/build.py::stage_backend` copies `backend/` via `shutil.copytree` rather than `git clone`, so even if Git for Windows were installed separately, the next `git fetch` would die on `fatal: not a git repository`. Adding Windows paths to `_find_executable` would only have changed which error message users saw. **Fix — distinct `update_method=windows_installer` instead of stretching the git path.** Same shape as the existing `docker` / `ha_addon` branches: detect the installer install, surface a download link to the release `.exe`, let the user run the installer like every other Windows app (Discord/Spotify model). New `_is_windows_installer_install()` helper returns True iff `sys.platform == "win32"` AND `settings.app_dir / ".git"` is absent — so a Windows developer with a real `git clone` keeps the git path, only installer users hit the new branch. New `_find_windows_installer_asset(release_data)` picks the matching asset out of the release's `assets[]` (prefer the versioned `bambuddy--windows-x64-setup.exe` because daily prereleases only publish that form; fall back to the unversioned `bambuddy-windows-x64-setup.exe` alias when present). `/updates/check` now includes `is_windows_installer: bool`, `update_method: "windows_installer"`, and `installer_download_url` on Windows-installer installs. `/updates/apply` short-circuits with `success: false, is_windows_installer: true` after the existing HA / Docker guards — defense in depth, since the frontend swaps the button so the POST shouldn't even fire on Windows. **Frontend.** `UpdateCheckResult` extended with `is_windows_installer`, `installer_download_url`, and `'windows_installer'` in the `update_method` union. `SettingsPage.tsx` inserts a new branch between the Docker snippet and the "Install Update" button: renders the same Bambu-green primary-button style as an `` pointing at `installer_download_url` (falls back to `release_url` then to the tag page so the link is never broken even if the release uploader is mid-publish). Hardcoded `data, settings and printers are preserved` reassurance in the body text — the installer preserves DATA_DIR by design, but Windows users who hit this UI mid-print would otherwise reasonably hesitate. `applyUpdateMutation.onSuccess` toast guard extended to treat `is_windows_installer` the same as HA / Docker, so a direct API call still surfaces the message instead of swallowing it. **i18n.** Two new keys — `settings.updateViaWindowsInstaller` (body copy) and `settings.downloadWindowsInstaller` (button label, takes `{{version}}`) — translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5216 leaves per locale, no English fallback. **Tests.** Backend: 8 new cases in `test_updates_api.py` — `_is_windows_installer_install` (× 3: true on `.git`-less Windows tmp_path, false when `.git` exists, false off-Windows), `_find_windows_installer_asset` (× 3: prefers versioned, falls back to alias, None when missing), `/updates/apply` rejection for installer installs, `/updates/check` returns `update_method="windows_installer"` + the asset URL. Frontend: 1 new case in `SettingsPage.test.tsx::'shows the installer-download link for Windows installer installs'` asserting the `` is rendered with the correct `href` / `target=_blank` / `rel*=noopener` AND the in-app Update button is suppressed AND the Docker/HA messages don't leak across. All 29 backend updates tests + all 44 SettingsPage frontend tests green; `npm run build` clean; ESLint clean; ruff clean. **Scope — what this does NOT change.** No installer change (no git.exe bundled, no `.git` seeded — those would be heavier fixes that still wouldn't match the user mental model of "double-click installer = update"); no behaviour change for Docker / HA / Linux / macOS / Windows-git-clone installs (the existing `update_method` selection prioritises HA → Docker → Windows-installer → git in that order; only `.git`-less Windows installs reach the new branch); no auto-download or in-app installer launch (UAC + NSSM service stop/start ordering would need a separate elevated helper — out of scope for this fix). + - **PrintModal printer picker no longer offers a printer between dispatch-accept and PRINT_START (reported off-list by a corporate user running multi-operator farm shifts)** — Operator picks a printer in the reprint modal, hits Send, Bambuddy accepts the dispatch and begins FTP upload + sending the print command. The printer hasn't reported `gcode_state=RUNNING` yet — it's still IDLE on its own MQTT status. A second operator opening the modal during this window sees the same printer as available and submits a second job. The backend correctly rejects the second submit with HTTP 409 (`background_dispatch._dispatch` rejects when `_queued_jobs` or `_active_jobs` already holds the printer_id), so no double-print is possible, but the operator only finds out after they click Send — wasted minutes per attempt on a busy floor. **Root cause:** `PrinterSelector.tsx::isPrinterBusy` consulted only `PrinterStatus.state` against `AVAILABLE_STATES = {IDLE, FINISH, FAILED}`. PRINT_START is the only signal that flips the printer out of IDLE, and there's a real wall-clock window (upload time + print command + firmware ack) between dispatch acceptance and that flip. The dispatch-queue state — already broadcast as a WebSocket `background_dispatch` push including `dispatched_jobs[].printer_id` and `active_jobs[].printer_id` — was being consumed by `ToastContext` for the progress overlay but never read by the picker. **Fix:** new `frontend/src/hooks/useDispatchedPrinterIds.ts` exposes `Set` of printer_ids with a queued or active dispatch, populated from the same `background-dispatch` window event the ToastContext listens for. Module-level singleton + `useSyncExternalStore` so every `PrinterSelector` instance sees the same snapshot and a modal opened mid-batch picks up the latest state without a refetch. Reference-stable snapshot (size + membership check) keeps `useSyncExternalStore`'s Object.is comparison from re-rendering on every WS push that doesn't change the set. `PrinterSelector.tsx::isPrinterBusy` ORs the set into the existing connected/state check — printer disabled the instant dispatch is accepted, re-enabled when the dispatch finishes (or fails) and disappears from the next state payload. `getPrinterStateLabel` returns `"Dispatching..."` for the badge so operators see the in-flight state instead of a misleading "Idle" on a now-disabled card. Hardcoded English label is consistent with the existing labels in that function (`"Idle"`, `"Printing"`, `"Paused"` are all hardcoded, no i18n key). **What this is NOT:** a backend change (the reservation Mike asked about already exists at `background_dispatch.py:283-290`); a behaviour change for `add-to-queue` / `edit-queue-item` modes (those don't set `disableBusy=true`, so the busy-OR remains dormant for the card click handler — the badge label still flips, which is informative); a guarantee against the WS-not-yet-connected race (a fresh page load that opens the modal before the WS initial-state push lands still sees an empty set for ~1 frame; same race as today, much shorter window). **Tests:** 8 new cases in `useDispatchedPrinterIds.test.ts` pin the contract — empty initial set, picks up `dispatched_jobs` printer_ids, picks up `active_jobs` printer_ids, unions both lists, clears when subsequent event reports zero jobs, ignores non-numeric `printer_id` (defensive against payload drift), reference-stable snapshot when content doesn't change, shared state across hook instances. Existing 84 PrintModal + PrinterSelector cases still green — the new code path is dormant until a `background-dispatch` window event fires, which existing tests don't trigger. `npm run build` clean, ESLint clean. - **Completion notification reported the whole project's duration and material usage when only one plate of a multi-plate 3MF was printed (#1785)** — Reporter (H2D) noticed the Discord on-print-complete message stated the full multi-plate project's totals (e.g. "6h 12m" / "370 g") even though only a single plate had been started, while every Bambuddy surface (print queue card, archive card, statistics) correctly showed the per-plate values. Root cause traced to the 3MF parser at `services/archive.py:200-264`, which sums per-plate `prediction` (slicer time estimate) and `weight` across every `` of a multi-plate file and stores those file-level totals on the `PrintArchive.print_time_seconds` / `PrintArchive.filament_used_grams` columns. That summing was added by #1593 to fix the archive card under-reporting on multi-plate files, and is correct for the archive-level "whole project" headline. The queue UI already re-reads the 3MF per-plate at `print_queue.py:272-285` (using `extract_filament_usage_from_3mf` / `_extract_print_time_from_3mf`) and substitutes the plate's actual values — which is why everything inside Bambuddy displays per-plate correctly. The notification path at `main.py::_background_notifications` read the archive's columns and `extra_data.filament_slots` directly with **no plate-aware override**, so the dispatched template variables (`{{duration}}`, `{{filament_grams}}`, `{{filament_details}}`) consistently rendered the project-wide sum. For material grams this was unconditionally wrong on multi-plate single-plate prints; for duration it depended on whether the real elapsed `actual_time_seconds` was populated (the `actual_time_seconds or print_time_seconds` fallback chain only landed on the summed estimate when the timestamps weren't usable). **Fix:** new `_scope_notification_archive_data_to_plate(archive_data, file_path, plate_id, status, progress, base_dir)` helper in `main.py` mirrors what the queue UI does — when `plate_id` is set on the just-completed print, re-read the 3MF, sum the plate's `` entries for the actual grams, read the plate's `` for the estimate, and replace `archive_data["actual_filament_grams"]` + `archive_data["print_time_seconds"]` + `archive_data["filament_slots"]` accordingly. `notify_plate_id` is captured from the existing `_print_plate_ids` register at the same point that already pops it (around `main.py:4183`), so no extra bookkeeping is added to the print-start path — the queue dispatcher and direct-Print path both already register plate_id there. **Partial-print scaling preserved:** the helper applies the same `progress / 100` scale factor to the per-plate grams + per-slot weights as the pre-existing summed-totals branch did, so a 50%-cancelled plate-2 print still reports "half the plate's grams," not the whole plate. **Fail-open on every error path:** missing `plate_id` (single-plate file / archive-only flow), missing `archive.file_path`, the 3MF file having been deleted between print completion and notification firing, a corrupt zip, or a plate index outside the file's range — all return `archive_data` unchanged so the notification still ships with the project-level numbers it would have shown before this fix. Same defensiveness shape as the helper-loaders the queue route relies on. **Hoisted `extract_print_time_from_3mf` into `utils/threemf_tools.py`** so the notification path can reuse the queue UI's logic without importing from a routes module (the route's `_extract_print_time_from_3mf` becomes a one-line alias). Identical signature + return shape, so the queue's existing call sites keep working without changes. **Tests:** 10 new cases. `test_threemf_tools.py::TestExtractPrintTimeFrom3mf` (7 cases): plate-N prediction returned for plate_id=N, first plate when no plate_id passed, None for plate_id outside range, None for unparseable prediction, None for missing slice_info / invalid zip / missing file. `test_notification_plate_scope.py::TestScopeNotificationArchiveDataToPlate` (10 cases): plate-2 of 3 collapses summed 370g/3h into plate's 120g/60min; plate-1 and plate-3 scope correctly; partial-print at progress=50 halves grams + per-slot weights but keeps full slicer estimate; no plate_id / no file_path / missing file / corrupt zip / out-of-range plate_id all return the input unchanged so the notification still sends; single-plate file with plate_id=1 is a clean no-op (the parser's sum already collapses to plate-1's values, no double-scaling). Full backend `pytest -n 30` 4086/4086 in 50s; ruff clean. **Scope clarification:** the archive card / project rollup / statistics surfaces stay on the summed totals (the original #1593 contract) — only the completion notification path now plate-scopes, mirroring the queue card precedent. Print Logs entries continue to use the per-run filament helper (#1378 / #1390) which already reads from `usage_results` + scales by progress, so this fix doesn't touch them. diff --git a/backend/app/api/routes/updates.py b/backend/app/api/routes/updates.py index 7586f864c..7e432f1cc 100644 --- a/backend/app/api/routes/updates.py +++ b/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--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 diff --git a/backend/tests/integration/test_updates_api.py b/backend/tests/integration/test_updates_api.py index bb1ec72f1..a0ae02ccb 100644 --- a/backend/tests/integration/test_updates_api.py +++ b/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") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 62b42750b..5dfc032bf 100644 --- a/frontend/package-lock.json +++ b/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" } diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index 58c5095bc..e9383f635 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/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', () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f07f5d419..fde19d12f 100644 --- a/frontend/src/api/client.ts +++ b/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('/updates/version'), checkForUpdates: () => request('/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('/updates/status'), diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 9f8736eb7..33353dd2b 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index bf352cbad..49f8f7890 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 8c9dc506d..f60eae732 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b0ab3b672..f331fbaf1 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index f92dbcded..40bcc8412 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index a3a4448be..4830d30fd 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 94bb2173a..e9fecaf2f 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index d6ba3dea1..acb06047a 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index c52b6953b..57d57ef8d 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index a9b58f095..351b3b958 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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}}', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index ca9c8bd27..5a69f7c51 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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}}', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4bd85f7a4..0c4be086a 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/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 + ) : updateCheck?.update_method === 'windows_installer' ? ( +
+

+ {t('settings.updateViaWindowsInstaller')} +

+
+ + {t('settings.downloadWindowsInstaller', { version: updateCheck.latest_version })} + +
) : (