diff --git a/src/main/ipc/localHandlers.remoteHttpRequest.test.ts b/src/main/ipc/localHandlers.remoteHttpRequest.test.ts index daab03250..166f0a8e8 100644 --- a/src/main/ipc/localHandlers.remoteHttpRequest.test.ts +++ b/src/main/ipc/localHandlers.remoteHttpRequest.test.ts @@ -40,6 +40,7 @@ function makeHandlers() { updatePowerSaveBlocker: vi.fn<() => void>(), autoUpdater: { initialize: vi.fn<() => void>(), + getStatus: vi.fn<() => null>(() => null), checkForUpdate: vi.fn<() => Promise>(async () => {}), startUpdateDownload: vi.fn<() => Promise>(async () => {}), installUpdate: vi.fn<() => void>(), diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 58b5146a3..e541eee05 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -551,6 +551,7 @@ export function createLocalIpcHandlers( upsertPrWatch: (watch) => options.prWatchService.upsert(watch), deletePrWatch: ({ projectId, prNumber }) => options.prWatchService.delete(projectId, prNumber), syncPrWatchAgent: (agent) => options.prWatchService.syncAgent(agent), + getUpdateStatus: () => options.autoUpdater.getStatus(), checkForUpdate: () => options.autoUpdater.checkForUpdate(), startUpdateDownload: () => options.autoUpdater.startUpdateDownload(), installUpdate: () => options.autoUpdater.installUpdate(), diff --git a/src/main/main.ts b/src/main/main.ts index f3ad03776..299dd26a1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -84,7 +84,6 @@ import { type PrWatchStatusEvent, type QuickComposerSubmission, type SupervisorEvent, - type UpdateStatus, } from "@/shared/ipc"; import type { SharedSettings } from "@/shared/settings"; import { readSharedSettingsFile, writeSharedSettingsFile } from "./sharedSettingsFile"; @@ -936,10 +935,6 @@ if (!hasSingleInstanceLock) { mainWindow?.webContents.send(IPC_EVENT_CHANNELS.gitStateChanged, patch); }, }); - // Latest updater status, captured from the auto-updater's status stream so - // the app-controls `check_for_update` tool can report the most recent - // result (the check itself is fire-and-forget and event-driven). - let lastUpdateStatus: UpdateStatus | null = null; appControlsMcpIngress = new AppControlsMcpIngress({ scheduleService, getThread: dbGetThread, @@ -982,7 +977,7 @@ if (!hasSingleInstanceLock) { }, checkForUpdate: async () => { await autoUpdaterController.checkForUpdate(); - const status = lastUpdateStatus; + const status = autoUpdaterController.getStatus(); const availableVersion = status && (status.type === "update-available" || status.type === "downloaded") ? status.version @@ -999,7 +994,6 @@ if (!hasSingleInstanceLock) { const autoUpdaterController = createAutoUpdaterController( (status) => { - lastUpdateStatus = status; mainWindow?.webContents.send(IPC_EVENT_CHANNELS.updateStatus, status); }, channel, @@ -1100,7 +1094,7 @@ if (!hasSingleInstanceLock) { gitStateService, updates: { currentVersion: () => app.getVersion(), - status: () => lastUpdateStatus, + status: () => autoUpdaterController.getStatus(), check: () => autoUpdaterController.checkForUpdate(), install: () => autoUpdaterController.installUpdate(), }, diff --git a/src/main/updates/autoUpdater.test.ts b/src/main/updates/autoUpdater.test.ts index fefb9450a..188ff427d 100644 --- a/src/main/updates/autoUpdater.test.ts +++ b/src/main/updates/autoUpdater.test.ts @@ -74,6 +74,16 @@ describe("createAutoUpdaterController", () => { expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true); }); + it("retains the latest status for a renderer that subscribes after the update finishes", () => { + const controller = createAutoUpdaterController(vi.fn(), "stable", false); + controller.initialize(); + + autoUpdaterMock.emit("update-available", { version: "1.2.3" }); + autoUpdaterMock.emit("update-downloaded", { version: "1.2.3" }); + + expect(controller.getStatus()).toEqual({ type: "downloaded", version: "1.2.3" }); + }); + it("starts the controller-owned download when a check finds an update", async () => { const controller = createAutoUpdaterController(vi.fn(), "stable", false); controller.initialize(); diff --git a/src/main/updates/autoUpdater.ts b/src/main/updates/autoUpdater.ts index 53672e64c..0808b143f 100644 --- a/src/main/updates/autoUpdater.ts +++ b/src/main/updates/autoUpdater.ts @@ -27,18 +27,20 @@ const TRANSIENT_REPORT_COOLDOWN_MS = 6 * 60 * 60 * 1_000; export interface AutoUpdaterController { initialize(): void; + getStatus(): UpdateStatus | null; checkForUpdate(): Promise; startUpdateDownload(): Promise; installUpdate(): void; } export function createAutoUpdaterController( - sendStatus: (status: UpdateStatus) => void, + onStatus: (status: UpdateStatus) => void, channel: PoracodeChannel, isDev: boolean, reportError: (error: unknown, tags?: PoracodeDiagnosticTags) => void = () => {}, beforeInstall: () => void = () => {}, ): AutoUpdaterController { + let lastStatus: UpdateStatus | null = null; let initialized = false; // True while a check or download is in flight; gates the periodic timer so a // scheduled tick never stacks a redundant check on top of an active one. @@ -59,6 +61,11 @@ export function createAutoUpdaterController( let activeAttempt: { operation: UpdateOperation; eventError: unknown | null } | null = null; const transientReportTimes = new Map(); + function sendStatus(status: UpdateStatus): void { + lastStatus = status; + onStatus(status); + } + function reportClassifiedFailure(operation: UpdateOperation, outcome: UpdateFailureKind): void { if (outcome === "optional-manifest-missing") { console.warn("[poracode] optional nightly update manifest is not available."); @@ -318,6 +325,7 @@ export function createAutoUpdaterController( return { initialize, + getStatus: () => lastStatus, checkForUpdate, startUpdateDownload, installUpdate, diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index fa06124a9..b620bf2bb 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -1,4 +1,5 @@ import { Fragment, type ReactNode } from "react"; +import { toast } from "@heroui/react"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +8,7 @@ import type { QuickComposerSubmission, SupervisorEvent, ThreadOpenRequestedEvent, + UpdateStatus, } from "@/shared/ipc"; import { useAppStore } from "./state/appStore"; import { useGitStore } from "./state/gitStore"; @@ -16,6 +18,7 @@ import { useExperimentStore } from "./state/experimentStore"; import { useWorkspaceStore } from "./state/workspaceStore"; import { resetDevTerminalStore, useDevTerminalStore } from "./state/devTerminalStore"; import { useThreadOutputStore } from "./state/threadOutputStore"; +import { useUpdateStore } from "./state/updateStore"; import { gitMergeAndRemove } from "@/renderer/actions/gitActions"; import { openThread, unloadThread } from "@/renderer/actions/threadActions"; @@ -186,6 +189,7 @@ const { gitWatchWorktrees: vi.fn<() => Promise>().mockResolvedValue(undefined), gitUnwatchProject: vi.fn<() => Promise>().mockResolvedValue(undefined), checkForUpdate: vi.fn<() => Promise>().mockResolvedValue(undefined), + getUpdateStatus: vi.fn<() => Promise>().mockResolvedValue(null), startUpdateDownload: vi.fn<() => Promise>().mockResolvedValue(undefined), installUpdate: vi.fn<() => Promise>().mockResolvedValue(undefined), relaunchApp: vi.fn<() => Promise>().mockResolvedValue(undefined), @@ -401,7 +405,7 @@ vi.mock("./state/sharedSettingsStore", () => ({ ), })); -import { App, STARTUP_RECOVERY_TIMEOUT_MS } from "./app"; +import { App, installUpdateStatusSync, STARTUP_RECOVERY_TIMEOUT_MS } from "./app"; describe("App", () => { const originalHasHydrated = useAppStore.persist.hasHydrated; @@ -437,6 +441,15 @@ describe("App", () => { })); resetDevTerminalStore(); useThreadOutputStore.setState({ buffers: {} }); + useUpdateStore.setState({ + phase: "idle", + version: null, + downloadPercent: 0, + errorMessage: null, + downloadTransferred: null, + downloadTotal: null, + downloadBytesPerSecond: null, + }); useExperimentStore.setState({ experiments: {} }); useGitStore.setState({ statuses: {}, @@ -467,6 +480,93 @@ describe("App", () => { vi.useRealTimers(); }); + it("restores a completed update when the renderer subscribed after it finished", async () => { + const unsubscribe = installUpdateStatusSync({ + getUpdateStatus: vi + .fn<() => Promise>() + .mockResolvedValue({ type: "downloaded", version: "1.2.3" }), + onUpdateStatus: vi.fn<() => () => void>(() => () => undefined), + }); + + await waitFor(() => { + expect(useUpdateStore.getState()).toMatchObject({ + phase: "downloaded", + version: "1.2.3", + downloadPercent: 100, + }); + }); + unsubscribe(); + }); + + it("does not let an older snapshot replace a newly started download", async () => { + let resolveSnapshot!: (status: UpdateStatus | null) => void; + let statusListener!: (status: UpdateStatus) => void; + const unsubscribe = installUpdateStatusSync({ + getUpdateStatus: vi.fn<() => Promise>( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ), + onUpdateStatus: vi.fn<(listener: (status: UpdateStatus) => void) => () => void>( + (listener) => { + statusListener = listener; + return () => undefined; + }, + ), + }); + + statusListener({ type: "update-available", version: "1.2.4" }); + resolveSnapshot({ type: "downloaded", version: "1.2.3" }); + await Promise.resolve(); + + expect(useUpdateStore.getState()).toMatchObject({ + phase: "downloading", + version: "1.2.4", + downloadPercent: 0, + }); + unsubscribe(); + }); + + it("ignores a snapshot that resolves after its subscription was disposed", async () => { + let resolveSnapshot!: (status: UpdateStatus | null) => void; + const unsubscribe = installUpdateStatusSync({ + getUpdateStatus: vi.fn<() => Promise>( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ), + onUpdateStatus: vi.fn<() => () => void>(() => () => undefined), + }); + + unsubscribe(); + useUpdateStore.getState().beginUpdateDownload("1.2.4"); + resolveSnapshot({ type: "downloaded", version: "1.2.3" }); + await Promise.resolve(); + + expect(useUpdateStore.getState()).toMatchObject({ phase: "downloading", version: "1.2.4" }); + }); + + it("restores an error snapshot without repeating its toast", async () => { + const danger = vi.spyOn(toast, "danger").mockImplementation(() => "toast-id"); + const unsubscribe = installUpdateStatusSync({ + getUpdateStatus: vi + .fn<() => Promise>() + .mockResolvedValue({ type: "error", message: "Update failed" }), + onUpdateStatus: vi.fn<() => () => void>(() => () => undefined), + }); + + await waitFor(() => { + expect(useUpdateStore.getState()).toMatchObject({ + phase: "error", + errorMessage: "Update failed", + }); + }); + expect(danger).not.toHaveBeenCalled(); + unsubscribe(); + }); + it("offers recovery controls when initial hydration does not finish", async () => { vi.useFakeTimers(); useAppStore.persist.hasHydrated = vi.fn<() => boolean>().mockReturnValue(false); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 37ef393db..79b5c3653 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -290,7 +290,7 @@ function installThreadOutputPruning(): () => void { }; } -function handleUpdateStatus(status: UpdateStatus): void { +function handleUpdateStatus(status: UpdateStatus, notifyError = true): void { const store = useUpdateStore.getState(); switch (status.type) { case "checking": @@ -315,12 +315,35 @@ function handleUpdateStatus(status: UpdateStatus): void { case "error": { const detail = status.messageKey ? msg(status.messageKey) : status.message; store.setError(detail); - toast.danger(msg("update.error", { detail })); + if (notifyError) toast.danger(msg("update.error", { detail })); break; } } } +export function installUpdateStatusSync( + bridge: Pick, "getUpdateStatus" | "onUpdateStatus"> = readBridge(), +): () => void { + let disposed = false; + let receivedLiveStatus = false; + const unsubscribe = bridge.onUpdateStatus((status) => { + receivedLiveStatus = true; + handleUpdateStatus(status); + }); + void bridge + .getUpdateStatus() + .then((status) => { + if (!disposed && !receivedLiveStatus && status) handleUpdateStatus(status, false); + }) + .catch((error: unknown) => { + if (!disposed) console.error("[poracode][updates] get-update-status failed", error); + }); + return () => { + disposed = true; + unsubscribe(); + }; +} + // The browser-extract window renders a standalone BrowserPanel; it has no use // for supervisor/update streams, remote-client bridges, or runtime persistence, // so only the main window wires these up (and tears them down on HMR dispose). @@ -328,7 +351,7 @@ const mainWindowCleanups: Array<() => void> = isMainWindow ? [ readBridge().onSupervisorEvent(handleSupervisorEvent), installRuntimeEventScheduling(), - readBridge().onUpdateStatus(handleUpdateStatus), + installUpdateStatusSync(), // Thread-metadata commands issued from paired remote clients (mobile PWA). // They run through the same actions as local edits so persistence and // side effects (unload on archive, …) stay identical. diff --git a/src/shared/ipc.test.ts b/src/shared/ipc.test.ts index 77cdd23f5..52b972f3e 100644 --- a/src/shared/ipc.test.ts +++ b/src/shared/ipc.test.ts @@ -94,6 +94,7 @@ describe("ipcProcedureMap", () => { updatePowerSaveBlocker: vi.fn<() => void>(), autoUpdater: { initialize: vi.fn<() => void>(), + getStatus: vi.fn<() => null>(() => null), checkForUpdate: vi.fn<() => Promise>(), startUpdateDownload: vi.fn<() => Promise>(), installUpdate: vi.fn<() => void>(), diff --git a/src/shared/ipc/procedureMap.ts b/src/shared/ipc/procedureMap.ts index 4744e4991..e16c5ebb2 100644 --- a/src/shared/ipc/procedureMap.ts +++ b/src/shared/ipc/procedureMap.ts @@ -138,6 +138,7 @@ export const MAIN_LOCAL_PROCEDURE_NAMES = [ "dbGetThreadContextUsage", "dbGetProjectNotes", "dbSetProjectNotes", + "getUpdateStatus", "checkForUpdate", "startUpdateDownload", "installUpdate", diff --git a/src/shared/ipc/procedures/updates.ts b/src/shared/ipc/procedures/updates.ts index 5b0c5a81a..f72d6ce8e 100644 --- a/src/shared/ipc/procedures/updates.ts +++ b/src/shared/ipc/procedures/updates.ts @@ -1,6 +1,11 @@ import { defineNoArgProcedure } from "../core"; +import type { UpdateStatus } from "../events"; export const updatesProcedures = { + getUpdateStatus: defineNoArgProcedure( + "getUpdateStatus", + "main-local", + ), checkForUpdate: defineNoArgProcedure("checkForUpdate", "main-local"), startUpdateDownload: defineNoArgProcedure( "startUpdateDownload",