diff --git a/apps/desktop/main/updater/update-manager.ts b/apps/desktop/main/updater/update-manager.ts index 4f403bc8c..80e33076f 100644 --- a/apps/desktop/main/updater/update-manager.ts +++ b/apps/desktop/main/updater/update-manager.ts @@ -227,7 +227,9 @@ export class UpdateManager { onChecking: () => { const diagnostic = this.getDiagnostic(); this.logCheck("update check event: checking for update", diagnostic); - this.send("update:checking", diagnostic); + if (this.userInitiatedCheck) { + this.send("update:checking", diagnostic); + } }, onAvailable: (info) => { const diagnostic = this.getDiagnostic({ @@ -400,8 +402,9 @@ export class UpdateManager { "update check: background download already complete, surfacing", this.getDiagnostic(), ); - // Brief "checking" flash so the settings button shows a transition - this.send("update:checking", this.getDiagnostic()); + if (this.userInitiatedCheck) { + this.send("update:checking", this.getDiagnostic()); + } this.send("update:downloaded", { version: this.pendingVersion }); return { updateAvailable: true }; } @@ -416,8 +419,9 @@ export class UpdateManager { this.getDiagnostic(), ); - // Brief "checking" flash so the settings button shows a transition - this.send("update:checking", this.getDiagnostic()); + if (this.userInitiatedCheck) { + this.send("update:checking", this.getDiagnostic()); + } const diagnostic = this.getDiagnostic({ remoteVersion: this.pendingVersion, diff --git a/apps/desktop/shared/host.ts b/apps/desktop/shared/host.ts index 89329b069..bfc752fb5 100644 --- a/apps/desktop/shared/host.ts +++ b/apps/desktop/shared/host.ts @@ -453,10 +453,7 @@ export type HostInvokeResultMap = { "update:download": { ok: boolean }; "update:install": undefined; "update:get-current-version": { version: string }; - "update:get-status": { - phase: "idle" | "downloading" | "ready"; - version: string | null; - }; + "update:get-status": DesktopUpdateStatus; "update:set-channel": { ok: boolean }; "update:set-source": { ok: boolean }; "component:check": { @@ -748,6 +745,12 @@ export interface UpdateCheckDiagnostic { remoteReleaseDate?: string; } +export interface DesktopUpdateStatus { + phase: "idle" | "downloading" | "ready"; + version: string | null; + percent: number; +} + export type UpdaterEventMap = { "update:checking": UpdateCheckDiagnostic; "update:available": { diff --git a/apps/desktop/src/hooks/use-auto-update.ts b/apps/desktop/src/hooks/use-auto-update.ts index 11329208d..0665c7c4f 100644 --- a/apps/desktop/src/hooks/use-auto-update.ts +++ b/apps/desktop/src/hooks/use-auto-update.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useState } from "react"; import type { DesktopUpdateCapability } from "../../shared/host"; +import type { DesktopUpdateStatus } from "../../shared/host"; import type { DesktopUpdateExperience } from "../../shared/update-policy"; import { checkForUpdate, downloadUpdate, getUpdateCapability, + getUpdateStatus, installUpdate, } from "../lib/host-api"; import { resolveLocale } from "../lib/i18n"; @@ -58,6 +60,30 @@ export function restorePhaseAfterInstall( : state; } +export function applyUpdateStatus( + state: UpdateState, + status: DesktopUpdateStatus, +): UpdateState { + if (state.phase === "installing") { + return state; + } + + if ( + (status.phase === "downloading" || status.phase === "ready") && + status.version + ) { + return { + ...state, + phase: status.phase, + version: status.version, + percent: status.percent, + dismissed: false, + }; + } + + return state; +} + export function useAutoUpdate(options?: { experience?: DesktopUpdateExperience; }) { @@ -97,6 +123,31 @@ export function useAutoUpdate(options?: { }; }, []); + useEffect(() => { + let cancelled = false; + + const pollStatus = () => { + void getUpdateStatus() + .then((status) => { + if (cancelled) { + return; + } + setState((prev) => applyUpdateStatus(prev, status)); + }) + .catch(() => { + // Ignore transient bridge errors and rely on event-driven updates. + }); + }; + + pollStatus(); + const pollTimer = window.setInterval(pollStatus, 1000); + + return () => { + cancelled = true; + window.clearInterval(pollTimer); + }; + }, []); + useEffect(() => { const updater = window.nexuUpdater; if (!updater) return; diff --git a/apps/desktop/src/lib/host-api.ts b/apps/desktop/src/lib/host-api.ts index 3bb0cd46c..5ffe0c730 100644 --- a/apps/desktop/src/lib/host-api.ts +++ b/apps/desktop/src/lib/host-api.ts @@ -2,6 +2,7 @@ import type { AppInfo, DesktopRuntimeConfig, DesktopUpdateCapability, + DesktopUpdateStatus, DiagnosticsExportResult, DiagnosticsInfo, HostDesktopCommand, @@ -202,6 +203,10 @@ export async function getUpdateCapability(): Promise { return getHostBridge().invoke("update:get-capability", undefined); } +export async function getUpdateStatus(): Promise { + return getHostBridge().invoke("update:get-status", undefined); +} + export async function downloadUpdate(): Promise { const result = await getHostBridge().invoke("update:download", undefined); return result.ok; diff --git a/tests/auto-update-install-phase.test.ts b/tests/auto-update-install-phase.test.ts index 097f2fb0b..d6eaf7c52 100644 --- a/tests/auto-update-install-phase.test.ts +++ b/tests/auto-update-install-phase.test.ts @@ -1,8 +1,67 @@ import { describe, expect, it } from "vitest"; -import { restorePhaseAfterInstall as restoreDesktopPhase } from "../apps/desktop/src/hooks/use-auto-update"; +import type { DesktopUpdateStatus } from "../apps/desktop/shared/host"; +import { + applyUpdateStatus, + restorePhaseAfterInstall as restoreDesktopPhase, +} from "../apps/desktop/src/hooks/use-auto-update"; import { restorePhaseAfterInstall as restoreWebPhase } from "../apps/web/src/hooks/use-auto-update"; describe("desktop useAutoUpdate", () => { + it("hydrates downloading state from polled main-process status", () => { + const status: DesktopUpdateStatus = { + phase: "downloading", + version: "1.2.3", + percent: 42, + }; + + expect( + applyUpdateStatus( + { + capability: null, + phase: "idle", + version: null, + releaseNotes: null, + actionUrl: null, + percent: 0, + errorMessage: null, + dismissed: true, + userInitiated: false, + }, + status, + ), + ).toMatchObject({ + phase: "downloading", + version: "1.2.3", + percent: 42, + dismissed: false, + }); + }); + + it("does not override the installing phase with polled status", () => { + const status: DesktopUpdateStatus = { + phase: "ready", + version: "1.2.3", + percent: 100, + }; + + expect( + applyUpdateStatus( + { + capability: null, + phase: "installing", + version: "1.0.0", + releaseNotes: null, + actionUrl: null, + percent: 0, + errorMessage: null, + dismissed: false, + userInitiated: false, + }, + status, + ).phase, + ).toBe("installing"); + }); + it("restores the prior actionable phase after install returns without quitting", () => { expect( restoreDesktopPhase( diff --git a/tests/desktop/update-manager-full.test.ts b/tests/desktop/update-manager-full.test.ts index 64e5dedd9..2dd254902 100644 --- a/tests/desktop/update-manager-full.test.ts +++ b/tests/desktop/update-manager-full.test.ts @@ -237,8 +237,9 @@ describe("bindEvents", () => { // checking-for-update // ------------------------------------------------------------------------- - it("checking-for-update: calls logCheck and sends update:checking", async () => { - const { win } = await createManager(); + it("checking-for-update: user-initiated checks send update:checking", async () => { + const { mgr, win } = await createManager(); + (mgr as { userInitiatedCheck: boolean }).userInitiatedCheck = true; const handlers = extractHandlers(); handlers["checking-for-update"](); @@ -263,6 +264,18 @@ describe("bindEvents", () => { ); }); + it("checking-for-update: background checks do not send update:checking", async () => { + const { win } = await createManager(); + const handlers = extractHandlers(); + + handlers["checking-for-update"](); + + expect(win.webContents.send).not.toHaveBeenCalledWith( + "update:checking", + expect.anything(), + ); + }); + // ------------------------------------------------------------------------- // update-available // -------------------------------------------------------------------------