diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts
index ad7475bacd8..d919d13ca69 100644
--- a/src/common/orpc/schemas/stream.ts
+++ b/src/common/orpc/schemas/stream.ts
@@ -780,6 +780,8 @@ export const UpdateStatusSchema = z.discriminatedUnion("type", [
}),
z.object({ type: z.literal("downloading"), percent: z.number().nullable() }),
z.object({ type: z.literal("downloaded"), info: z.object({ version: z.string() }) }),
+ // Emitted synchronously once an install is going ahead, before the process restarts.
+ z.object({ type: z.literal("restarting"), info: z.object({ version: z.string() }) }),
z.object({
type: z.literal("error"),
phase: z.enum(["check", "download", "install"]),
diff --git a/src/desktop/updater.test.ts b/src/desktop/updater.test.ts
index 2e033568a21..8bb59977832 100644
--- a/src/desktop/updater.test.ts
+++ b/src/desktop/updater.test.ts
@@ -775,6 +775,49 @@ describe("UpdaterService", () => {
expect(mockUpdateInstallInProgress).toBe(false);
});
+
+ it("should notify subscribers of restarting before quitAndInstall runs", () => {
+ mockAutoUpdater.emit("update-available", { version: "2.0.0" });
+ mockAutoUpdater.emit("update-downloaded", { version: "2.0.0" });
+
+ const order: string[] = [];
+ service.subscribe((status) => order.push(status.type));
+ mockAutoUpdater.quitAndInstall.mockImplementationOnce(() => {
+ order.push("quitAndInstall");
+ });
+
+ service.installUpdate();
+
+ expect(order).toEqual(["restarting", "quitAndInstall"]);
+ expect(service.getStatus()).toMatchObject({ type: "restarting", info: { version: "2.0.0" } });
+ });
+
+ it("should report the downloaded version when retrying after an install error", () => {
+ mockAutoUpdater.emit("update-downloaded", { version: "2.0.0" });
+ mockAutoUpdater.quitAndInstall.mockImplementationOnce(() => {
+ throw new Error("Install failed due to permission error");
+ });
+ service.installUpdate();
+ expect(service.getStatus()).toMatchObject({ type: "error", phase: "install" });
+
+ service.installUpdate();
+
+ expect(service.getStatus()).toMatchObject({ type: "restarting", info: { version: "2.0.0" } });
+ });
+
+ it("should not emit restarting from the DEBUG_UPDATER fake install path", async () => {
+ process.env.DEBUG_UPDATER = "2.0.0";
+ const debugService = new UpdaterService();
+ const statuses: UpdateStatus[] = [];
+ debugService.subscribe((status) => statuses.push(status));
+ await debugService.downloadUpdate();
+
+ debugService.installUpdate();
+
+ expect(statuses.some((status) => status.type === "restarting")).toBe(false);
+ expect(debugService.getStatus().type).toBe("downloaded");
+ expect(mockAutoUpdater.quitAndInstall).not.toHaveBeenCalled();
+ });
});
describe("state guards", () => {
diff --git a/src/desktop/updater.ts b/src/desktop/updater.ts
index a901967a203..0609cc47787 100644
--- a/src/desktop/updater.ts
+++ b/src/desktop/updater.ts
@@ -94,6 +94,7 @@ export type UpdateStatus =
| { type: "up-to-date" } // Explicitly checked, no updates available
| { type: "downloading"; percent: number }
| { type: "downloaded"; info: UpdateInfo }
+ | { type: "restarting"; info: UpdateInfo }
| { type: "error"; phase: "check" | "download" | "install"; message: string };
/**
@@ -116,6 +117,8 @@ export class UpdaterService {
install: 0,
};
private checkSource: "auto" | "manual" = "auto";
+ // Kept so an install retry after an install error still knows which version is restarting.
+ private downloadedInfo: UpdateInfo | null = null;
private subscribers = new Set<(status: UpdateStatus) => void>();
private currentChannel: UpdateChannel = "stable";
@@ -222,6 +225,7 @@ export class UpdaterService {
autoUpdater.on("update-downloaded", (info: UpdateInfo) => {
log.info("Update downloaded:", info.version);
+ this.downloadedInfo = info;
this.updateStatus = { type: "downloaded", info };
this.notifyRenderer();
});
@@ -305,7 +309,7 @@ export class UpdaterService {
// Skip when a check/download is already in progress or an update
// is ready to install — the 4-hour interval fires unconditionally,
// and we don't want it clobbering active states.
- const dominated = ["checking", "downloading", "downloaded"] as const;
+ const dominated = ["checking", "downloading", "downloaded", "restarting"] as const;
if ((dominated as readonly string[]).includes(this.updateStatus.type)) {
// If a check is already in flight and the user explicitly triggers a manual
// check, upgrade the source so transient failures surface to the user.
@@ -470,6 +474,7 @@ export class UpdaterService {
// Mark as downloaded
const version = this.fakeVersion;
const fakeDownloadedInfo = { version } satisfies Partial
as UpdateInfo;
+ this.downloadedInfo = fakeDownloadedInfo;
this.updateStatus = {
type: "downloaded",
info: fakeDownloadedInfo,
@@ -491,9 +496,11 @@ export class UpdaterService {
* Install a downloaded update and restart the app
*/
installUpdate(): void {
+ const info = this.downloadedInfo;
if (
- this.updateStatus.type !== "downloaded" &&
- !(this.updateStatus.type === "error" && this.updateStatus.phase === "install")
+ info === null ||
+ (this.updateStatus.type !== "downloaded" &&
+ !(this.updateStatus.type === "error" && this.updateStatus.phase === "install"))
) {
throw new Error("No update downloaded to install");
}
@@ -514,6 +521,11 @@ export class UpdaterService {
return;
}
+ // The renderer must swap to the restart screen before quitAndInstall(), which can block for
+ // seconds (Squirrel on macOS) or spawn an installer while the window is still visible.
+ this.updateStatus = { type: "restarting", info };
+ this.notifyRenderer();
+
try {
markUpdateInstallInProgress();
autoUpdater.quitAndInstall();
diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts
index 07b923f36e2..f1cc0078d25 100644
--- a/src/node/services/serverUpdate/serverUpdate.test.ts
+++ b/src/node/services/serverUpdate/serverUpdate.test.ts
@@ -763,18 +763,26 @@ describe("server updater", () => {
});
await updater.checkForUpdates();
await updater.downloadUpdate();
+ const restarting: UpdateStatus[] = [];
+ updater.subscribe((status) => {
+ events.push(`status:${status.type}`);
+ if (status.type === "restarting") restarting.push(status);
+ });
+ events.length = 0;
await updater.installUpdate();
expect(updater.getStatus()).toMatchObject({ type: "install-blocked", blockers });
- expect(events).toEqual(["refresh", "snapshot"]);
+ expect(events).toEqual(["refresh", "snapshot", "status:install-blocked"]);
blockers = [];
events.length = 0;
await updater.installUpdate();
expect(updater.getStatus()).toMatchObject({ type: "error", phase: "install" });
- expect(events).toEqual(["refresh", "snapshot"]);
+ // The client drops its restart screen on the install error that follows.
+ expect(events).toEqual(["refresh", "snapshot", "status:restarting", "status:error"]);
activationFails = false;
events.length = 0;
await Promise.all([updater.installUpdate(), updater.installUpdate()]);
- expect(events).toEqual(["refresh", "snapshot", "activate", "restart"]);
+ expect(events).toEqual(["refresh", "snapshot", "status:restarting", "activate", "restart"]);
+ expect(restarting.at(-1)).toEqual({ type: "restarting", info: { version: "2.0.0" } });
});
test("a forced install restarts despite blockers without consulting them", async () => {
const { layout } = await fixture();
@@ -806,9 +814,10 @@ describe("server updater", () => {
const updater = await stagedUpdater();
await updater.installUpdate();
expect(updater.getStatus().type).toBe("install-blocked");
+ updater.subscribe((status) => events.push(`status:${status.type}`));
events.length = 0;
await updater.installUpdate({ force: true });
- expect(events).toEqual(["activate", "restart"]);
+ expect(events).toEqual(["status:restarting", "activate", "restart"]);
// An unrelated teardown already under way still wins over a forced install.
const shuttingDown = await stagedUpdater();
diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts
index 2a556eaf08c..1b202b4af43 100644
--- a/src/node/services/serverUpdate/serverUpdater.ts
+++ b/src/node/services/serverUpdate/serverUpdater.ts
@@ -198,7 +198,10 @@ export class ServerUpdater {
return;
}
}
- // No await between the idle snapshot, atomic swap, and the CLI's shutdown latch.
+ // No await between the idle snapshot, the (synchronous) restarting broadcast, atomic swap,
+ // and the CLI's shutdown latch. Clients swap to the restart screen on this status; the
+ // graceful restart below can take up to the teardown budget.
+ this.setStatus({ type: "restarting", info: { version: staged.version } });
(this.deps.activate ?? activateUpdate)(this.layout, staged.entry);
await this.deps.restart();
} catch (error) {
From 7e330f308b7d8e044b551874a444bd26584fe0d4 Mon Sep 17 00:00:00 2001
From: Mike Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Sun, 13 Sep 2026 18:10:57 +0000
Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20the=20restart?=
=?UTF-8?q?=20screen=20up=20during=20channel=20switches=20and=20dialog=20c?=
=?UTF-8?q?licks?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- desktop UpdaterService.setChannel now refuses while an install is restarting,
so the status cannot be reset to idle mid-quitAndInstall (found in UAT)
- UpdateRestartOverlay opts back into pointer events under Radix's modal body lock
---
.../UpdateRestartOverlay/UpdateRestartOverlay.tsx | 4 +++-
src/desktop/updater.test.ts | 14 ++++++++++++++
src/desktop/updater.ts | 6 ++++--
3 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
index 7244152473c..b56298300a6 100644
--- a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
+++ b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
@@ -48,9 +48,11 @@ export function UpdateRestartOverlay() {
}
// Stacked above dialogs, toasts, and menus so nothing from the old UI peeks through.
+ // pointer-events-auto: Radix's modal lock sets pointer-events: none on while the About
+ // dialog (the usual install trigger) is open, which would let clicks fall through the cover.
return (
diff --git a/src/desktop/updater.test.ts b/src/desktop/updater.test.ts
index 8bb59977832..6734d313f48 100644
--- a/src/desktop/updater.test.ts
+++ b/src/desktop/updater.test.ts
@@ -178,6 +178,20 @@ describe("UpdaterService", () => {
expect(() => channelService.setChannel("nightly")).toThrow("ready to install");
});
+ it("setChannel throws while an install is restarting", () => {
+ mockAutoUpdater.setFeedURL.mockClear();
+ const channelService = new UpdaterService();
+ const statuses: string[] = [];
+ channelService.subscribe((status) => statuses.push(status.type));
+
+ mockAutoUpdater.emit("update-downloaded", { version: "2.0.0" });
+ channelService.installUpdate();
+
+ expect(() => channelService.setChannel("nightly")).toThrow("installing");
+ expect(channelService.getStatus().type).toBe("restarting");
+ expect(statuses).not.toContain("idle");
+ });
+
it("setChannel notifies subscribers on switch", () => {
mockAutoUpdater.setFeedURL.mockClear();
const channelService = new UpdaterService();
diff --git a/src/desktop/updater.ts b/src/desktop/updater.ts
index 0609cc47787..4f7c97a6b64 100644
--- a/src/desktop/updater.ts
+++ b/src/desktop/updater.ts
@@ -547,10 +547,12 @@ export class UpdaterService {
return;
}
- const blockedStates = ["checking", "downloading", "downloaded"] as const;
+ // "restarting" is blocked too: resetting to idle here would drop the renderer's restart
+ // screen while quitAndInstall() is still in flight.
+ const blockedStates = ["checking", "downloading", "downloaded", "restarting"] as const;
if ((blockedStates as readonly string[]).includes(this.updateStatus.type)) {
throw new Error(
- `Cannot switch update channel while ${this.updateStatus.type === "checking" ? "checking for updates" : this.updateStatus.type === "downloading" ? "downloading an update" : "an update is ready to install"}`
+ `Cannot switch update channel while ${this.updateStatus.type === "checking" ? "checking for updates" : this.updateStatus.type === "downloading" ? "downloading an update" : this.updateStatus.type === "restarting" ? "an update is installing" : "an update is ready to install"}`
);
}
From 494e51c9abc257bc30848b4502a91d33b80d708d Mon Sep 17 00:00:00 2001
From: Mike Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Sun, 13 Sep 2026 18:18:27 +0000
Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20tests:=20load=20the=20boot?=
=?UTF-8?q?=20loader=20styles=20in=20Storybook?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
LoadingScreen (and the restart overlay) rely on .boot-loader rules that only
exist inline in index.html, so their stories rendered unstyled (top-left, no
sizing). Lift those rules into the preview head from index.html itself.
---
.storybook/main.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 5ce57dcc59e..bd37a1351ca 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,7 +1,21 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
+import { readFileSync } from "fs";
import path from "path";
+// The boot loader's CSS is inlined in index.html so the pre-JS placeholder paints styled, and
+// LoadingScreen (plus the update restart overlay built on it) reuses those classes. Lift the same
+// rules into the preview so stories render them faithfully without a second copy of the CSS.
+function bootLoaderStyles(): string {
+ const html = readFileSync(path.join(process.cwd(), "index.html"), "utf8");
+ const start = html.indexOf(".boot-loader {");
+ const end = html.indexOf("", start);
+ if (start === -1 || end === -1) {
+ throw new Error("index.html no longer contains the inline .boot-loader styles");
+ }
+ return html.slice(start, end);
+}
+
const config: StorybookConfig = {
stories: [
"../src/browser/stories/**/*.stories.@(ts|tsx)",
@@ -9,6 +23,7 @@ const config: StorybookConfig = {
"../src/browser/features/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-links", "@storybook/addon-docs"],
+ previewHead: (head) => `${head ?? ""}`,
framework: {
name: "@storybook/react-vite",
options: {},
From 50dbb7bf27220e183123750dfb255e9c99ce7713 Mon Sep 17 00:00:00 2001
From: Mike Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Sun, 13 Sep 2026 21:17:19 +0000
Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20the=20restart?=
=?UTF-8?q?=20screen=20accessible=20and=20classify=20restart-time=20errors?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- updater errors reported through electron-updater's error event while
restarting now map to the install phase (retry stays available)
- UpdateRestartOverlay portals to so an open Radix modal's aria-hidden
on the app root cannot hide the status from assistive technology
- the About dialog closes itself when restarting lands, releasing its focus
trap and aria-hidden so the restart screen is what keyboard/AT land on
---
.../UpdateRestartOverlay/UpdateRestartOverlay.tsx | 14 +++++++++-----
.../features/About/AboutDialog.stories.tsx | 15 +++++++--------
src/browser/features/About/AboutDialog.tsx | 8 +++++++-
src/desktop/updater.test.ts | 15 +++++++++++++++
src/desktop/updater.ts | 4 +++-
5 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
index b56298300a6..2ede152ed75 100644
--- a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
+++ b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
import { useAPI } from "@/browser/contexts/API";
import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen";
@@ -47,15 +48,18 @@ export function UpdateRestartOverlay() {
return null;
}
- // Stacked above dialogs, toasts, and menus so nothing from the old UI peeks through.
- // pointer-events-auto: Radix's modal lock sets pointer-events: none on while the About
- // dialog (the usual install trigger) is open, which would let clicks fall through the cover.
- return (
+ // Portaled to , outside the app root: an open Radix modal (the About dialog is the usual
+ // install trigger) marks the app root aria-hidden and locks body pointer events, so rendering
+ // inside it would hide the status from assistive technology; pointer-events-auto keeps clicks
+ // from falling through the cover. Stacked above dialogs, toasts, and menus so nothing from the
+ // old UI peeks through.
+ return createPortal(
-
+
,
+ document.body
);
}
diff --git a/src/browser/features/About/AboutDialog.stories.tsx b/src/browser/features/About/AboutDialog.stories.tsx
index 3e535cda281..c15dfc98fc0 100644
--- a/src/browser/features/About/AboutDialog.stories.tsx
+++ b/src/browser/features/About/AboutDialog.stories.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, userEvent, within } from "@storybook/test";
+import { expect, userEvent, waitFor, within } from "@storybook/test";
import type { UpdateStatus } from "@/common/orpc/types";
import { APIProvider } from "@/browser/contexts/API";
import { AboutDialogProvider, useAboutDialog } from "@/browser/contexts/AboutDialogContext";
@@ -75,16 +75,15 @@ export const Downloading: Story = {
},
};
-export const Restarting: Story = {
+export const RestartingClosesDialog: Story = {
args: { status: { type: "restarting", info: { version: "0.29.0" } } },
play: async (context) => {
await meta.play(context);
- const dialog = await within(document.body).findByRole("dialog");
- await expect(within(dialog).getByRole("button", { name: "Check for Updates" })).toBeDisabled();
- await expect(
- within(dialog).queryByRole("button", { name: "Install & restart" })
- ).not.toBeInTheDocument();
- await expect(within(dialog).getByRole("radio", { name: "Newest npm" })).toBeDisabled();
+ // The dialog hands the screen to the restart cover as soon as the restarting status lands,
+ // releasing its focus trap; the mock emits that status on subscribe, right after opening.
+ await waitFor(() =>
+ expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument()
+ );
},
};
diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx
index ba1472cf59e..3b8beafc49e 100644
--- a/src/browser/features/About/AboutDialog.tsx
+++ b/src/browser/features/About/AboutDialog.tsx
@@ -117,6 +117,12 @@ export function AboutDialog() {
}
setUpdateStatus(status);
setPendingAction(null);
+ // The restart screen takes over; releasing this modal also releases its focus trap and
+ // the aria-hidden it put on the app, so the restarting status is what keyboard and
+ // assistive technology land on.
+ if (status.type === "restarting") {
+ close();
+ }
}
} catch (error) {
if (!signal.aborted) {
@@ -128,7 +134,7 @@ export function AboutDialog() {
return () => {
controller.abort();
};
- }, [api, isOpen]);
+ }, [api, close, isOpen]);
useEffect(() => {
if (!isOpen || !api) {
diff --git a/src/desktop/updater.test.ts b/src/desktop/updater.test.ts
index 6734d313f48..1293e4f7d48 100644
--- a/src/desktop/updater.test.ts
+++ b/src/desktop/updater.test.ts
@@ -623,6 +623,21 @@ describe("UpdaterService", () => {
});
});
+ it("should map updater errors reported while restarting to install phase", () => {
+ mockAutoUpdater.emit("update-available", { version: "2.0.0" });
+ mockAutoUpdater.emit("update-downloaded", { version: "2.0.0" });
+ service.installUpdate();
+ expect(service.getStatus().type).toBe("restarting");
+
+ mockAutoUpdater.emit("error", new Error("Could not launch the installer"));
+
+ expect(statusUpdates[statusUpdates.length - 1]).toEqual({
+ type: "error",
+ phase: "install",
+ message: "Could not launch the installer",
+ });
+ });
+
it("should preserve existing error phase on follow-up updater errors", () => {
mockAutoUpdater.emit("update-available", { version: "2.0.0" });
mockAutoUpdater.emit("download-progress", { percent: 30 });
diff --git a/src/desktop/updater.ts b/src/desktop/updater.ts
index 4f7c97a6b64..2a24014be90 100644
--- a/src/desktop/updater.ts
+++ b/src/desktop/updater.ts
@@ -262,10 +262,12 @@ export class UpdaterService {
return;
}
+ // quitAndInstall() can also fail through this event (missing installer, native updater
+ // error); that is an install failure the user may retry, not a check failure.
const phase =
this.updateStatus.type === "downloading"
? "download"
- : this.updateStatus.type === "downloaded"
+ : this.updateStatus.type === "downloaded" || this.updateStatus.type === "restarting"
? "install"
: this.updateStatus.type === "error"
? this.updateStatus.phase