diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 99bc636248..aebd9f1cab 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -29,7 +29,16 @@ import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge"; import { useComponentUpdates, type ComponentUpdateCategory } from "@/composables/useComponentUpdates"; import { COMPONENT_UPDATES_CHANGED_EVENT, notifyComponentPluginsUpdated, notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; import { driverStoreUpdateBadgeCount, showMcpUpdateBadge, showToolbarUpdateAction } from "@/lib/updates/updateBadges"; -import { markPendingComponentUpdatesAfterAppUpdate, resolveUpdateAllAction, runPendingComponentUpdatePlan, shouldCloseUpdateCenterAfterComponentUpdate, takePendingComponentUpdatesAfterAppRestart, type PendingComponentUpdatePlan } from "@/lib/updates/componentUpdateOrchestration"; +import { + hasPendingComponentUpdatesAfterAppRestart, + markPendingComponentUpdatesAfterAppUpdate, + resolveUpdateAllAction, + runPendingComponentUpdatesBeforePluginReconnect, + runPendingComponentUpdatePlan, + shouldCloseUpdateCenterAfterComponentUpdate, + takePendingComponentUpdatesAfterAppRestart, + type PendingComponentUpdatePlan, +} from "@/lib/updates/componentUpdateOrchestration"; import { isUpdatePreviewMockEnabled } from "@/lib/updates/updatePreviewMock"; import { useExportTracker } from "@/composables/useExportTracker"; import { useFileDrop } from "@/composables/useFileDrop"; @@ -3776,20 +3785,22 @@ async function initApp() { onOptionalStateError: (error) => console.error("[STARTUP] settingsStore.initAiConfigs failed", error), }); } - // Restored plugin tabs need the sidecar connection registry repopulated - // (see reconnectRestoredPluginTabs); kick it off before the heavier - // optional init so it races ahead of each plugin webview's first - // session/open. Fire-and-forget: it must never block startup. - void queryStore.reconnectRestoredPluginTabs(); - await settingsStore.initDesktopSettings().catch(() => {}); - if (isDesktop) { - updateWindowReady = true; - await initializeUpdatePreparation(); - await initializeUpdater(); - if (!isDetachedWindowContext) { - void consumePendingComponentUpdatesAfterRestart(); - } - } + await runPendingComponentUpdatesBeforePluginReconnect({ + hasPendingComponentUpdates: () => !isDetachedWindowContext && hasPendingComponentUpdatesAfterAppRestart(), + prepareStartup: async () => { + await settingsStore.initDesktopSettings().catch(() => {}); + if (isDesktop) { + updateWindowReady = true; + await initializeUpdatePreparation(); + await initializeUpdater(); + } + }, + consumePendingComponentUpdates: consumePendingComponentUpdatesAfterRestart, + // Restored plugin tabs need the sidecar connection registry repopulated + // (see reconnectRestoredPluginTabs). It is fire-and-forget so a slow + // sidecar or interactive prompt never blocks startup. + reconnectRestoredPluginTabs: async () => queryStore.reconnectRestoredPluginTabs(), + }); void promptTemplateStore.init(); diff --git a/apps/desktop/src/lib/updates/__tests__/componentUpdateOrchestration.spec.ts b/apps/desktop/src/lib/updates/__tests__/componentUpdateOrchestration.spec.ts index 27953f9cb7..e74bd84f42 100644 --- a/apps/desktop/src/lib/updates/__tests__/componentUpdateOrchestration.spec.ts +++ b/apps/desktop/src/lib/updates/__tests__/componentUpdateOrchestration.spec.ts @@ -1,7 +1,16 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearPendingComponentUpdatesAfterAppUpdate, markPendingComponentUpdatesAfterAppUpdate, resolveUpdateAllAction, runPendingComponentUpdatePlan, shouldCloseUpdateCenterAfterComponentUpdate, takePendingComponentUpdatesAfterAppRestart } from "@/lib/updates/componentUpdateOrchestration"; +import { + clearPendingComponentUpdatesAfterAppUpdate, + hasPendingComponentUpdatesAfterAppRestart, + markPendingComponentUpdatesAfterAppUpdate, + resolveUpdateAllAction, + runPendingComponentUpdatesBeforePluginReconnect, + runPendingComponentUpdatePlan, + shouldCloseUpdateCenterAfterComponentUpdate, + takePendingComponentUpdatesAfterAppRestart, +} from "@/lib/updates/componentUpdateOrchestration"; describe("component update orchestration", () => { beforeEach(() => { @@ -16,6 +25,85 @@ describe("component update orchestration", () => { expect(takePendingComponentUpdatesAfterAppRestart("0.6.17")).toEqual({ fromVersion: "0.6.16", targetVersion: "0.6.17", plan: { kind: "auto" } }); }); + it("detects pending component updates before restored plugin tabs reconnect", () => { + expect(hasPendingComponentUpdatesAfterAppRestart()).toBe(false); + + markPendingComponentUpdatesAfterAppUpdate("0.6.18", "0.6.19", { kind: "manual", categories: ["plugins"] }); + + expect(hasPendingComponentUpdatesAfterAppRestart()).toBe(true); + expect(takePendingComponentUpdatesAfterAppRestart("0.6.19")).not.toBeNull(); + expect(hasPendingComponentUpdatesAfterAppRestart()).toBe(false); + }); + + it("consumes pending component updates before reconnecting restored plugin tabs", async () => { + const events: string[] = []; + + await runPendingComponentUpdatesBeforePluginReconnect({ + hasPendingComponentUpdates: () => true, + prepareStartup: async () => { + events.push("prepare"); + }, + consumePendingComponentUpdates: async () => { + events.push("consume"); + }, + reconnectRestoredPluginTabs: async () => { + events.push("reconnect"); + }, + }); + + expect(events).toEqual(["prepare", "consume", "reconnect"]); + }); + + it("reconnects restored plugin tabs immediately when no component update is pending", async () => { + const events: string[] = []; + let finishStartup = () => {}; + const startupFinished = new Promise((resolve) => { + finishStartup = resolve; + }); + + const startup = runPendingComponentUpdatesBeforePluginReconnect({ + hasPendingComponentUpdates: () => false, + prepareStartup: async () => { + events.push("prepare"); + await startupFinished; + }, + consumePendingComponentUpdates: async () => { + events.push("consume"); + }, + reconnectRestoredPluginTabs: async () => { + events.push("reconnect"); + }, + }); + + await Promise.resolve(); + expect(events).toEqual(["reconnect", "prepare"]); + finishStartup(); + await startup; + expect(events).toEqual(["reconnect", "prepare"]); + }); + + it("still reconnects restored plugin tabs when pending component updates fail", async () => { + const events: string[] = []; + + await expect( + runPendingComponentUpdatesBeforePluginReconnect({ + hasPendingComponentUpdates: () => true, + prepareStartup: async () => { + events.push("prepare"); + }, + consumePendingComponentUpdates: async () => { + events.push("consume"); + throw new Error("update failed"); + }, + reconnectRestoredPluginTabs: async () => { + events.push("reconnect"); + }, + }), + ).rejects.toThrow("update failed"); + + expect(events).toEqual(["prepare", "consume", "reconnect"]); + }); + it("consumes a successful restart exactly once", () => { markPendingComponentUpdatesAfterAppUpdate("0.6.16", "0.6.17"); diff --git a/apps/desktop/src/lib/updates/componentUpdateOrchestration.ts b/apps/desktop/src/lib/updates/componentUpdateOrchestration.ts index a63e5dc218..0bcef1cfd7 100644 --- a/apps/desktop/src/lib/updates/componentUpdateOrchestration.ts +++ b/apps/desktop/src/lib/updates/componentUpdateOrchestration.ts @@ -79,6 +79,25 @@ export function clearPendingComponentUpdatesAfterAppUpdate() { safeLocalStorageRemove(PENDING_COMPONENT_UPDATES_STORAGE_KEY); } +export function hasPendingComponentUpdatesAfterAppRestart(): boolean { + return parsePendingComponentUpdates(safeLocalStorageGet(PENDING_COMPONENT_UPDATES_STORAGE_KEY)) !== null; +} + +export async function runPendingComponentUpdatesBeforePluginReconnect(options: { hasPendingComponentUpdates: () => boolean; prepareStartup: () => Promise; consumePendingComponentUpdates: () => Promise; reconnectRestoredPluginTabs: () => Promise }): Promise { + if (!options.hasPendingComponentUpdates()) { + void options.reconnectRestoredPluginTabs(); + await options.prepareStartup(); + return; + } + + try { + await options.prepareStartup(); + await options.consumePendingComponentUpdates(); + } finally { + void options.reconnectRestoredPluginTabs(); + } +} + export function takePendingComponentUpdatesAfterAppRestart(currentVersion: string): PendingComponentUpdates | null { const pending = parsePendingComponentUpdates(safeLocalStorageGet(PENDING_COMPONENT_UPDATES_STORAGE_KEY)); if (!pending) {