From 449e2c556be5f3fc08a7c5595b1b7ea5a2d48962 Mon Sep 17 00:00:00 2001 From: Mike Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:24:04 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20feat:=20show=20a=20full-scre?= =?UTF-8?q?en=20restarting=20screen=20while=20an=20update=20installs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both updaters jumped from "downloaded" straight to the process restart, so "Install & restart" left the old UI on screen for seconds (Squirrel handshake on macOS, graceful server teardown on npm) and then the app vanished. - Add a "restarting" UpdateStatus emitted synchronously once the install is going ahead: the desktop updater before quitAndInstall(), the server updater after the blocker gate and before activation (also on the forced path). - New UpdateRestartOverlay covers the whole app the moment that status arrives and stays up through the reconnect gap until the relaunched server reports another status, or an install error clears it. - About dialog shows the restarting state and disables update actions; the title bar badge spins. --- .../components/AppLoader/AppLoader.tsx | 2 + src/browser/components/TitleBar/TitleBar.tsx | 6 +- .../UpdateRestartOverlay.stories.tsx | 52 ++++++ .../UpdateRestartOverlay.test.tsx | 159 ++++++++++++++++++ .../UpdateRestartOverlay.tsx | 59 +++++++ .../features/About/AboutDialog.stories.tsx | 13 ++ src/browser/features/About/AboutDialog.tsx | 17 +- src/common/orpc/schemas/stream.ts | 2 + src/desktop/updater.test.ts | 43 +++++ src/desktop/updater.ts | 18 +- .../serverUpdate/serverUpdate.test.ts | 17 +- .../services/serverUpdate/serverUpdater.ts | 5 +- 12 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.stories.tsx create mode 100644 src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.test.tsx create mode 100644 src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx diff --git a/src/browser/components/AppLoader/AppLoader.tsx b/src/browser/components/AppLoader/AppLoader.tsx index 1a82086bd2c..e2b794aca01 100644 --- a/src/browser/components/AppLoader/AppLoader.tsx +++ b/src/browser/components/AppLoader/AppLoader.tsx @@ -24,6 +24,7 @@ import { UserPreferencesProvider, } from "@/browser/contexts/UserPreferencesContext"; import { TerminalRouterProvider } from "../../terminal/TerminalRouterContext"; +import { UpdateRestartOverlay } from "@/browser/components/UpdateRestartOverlay/UpdateRestartOverlay"; const USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS = 2000; @@ -271,6 +272,7 @@ function AppLoaderInner() { > + )} diff --git a/src/browser/components/TitleBar/TitleBar.tsx b/src/browser/components/TitleBar/TitleBar.tsx index df758a67470..3e1cfc70bd8 100644 --- a/src/browser/components/TitleBar/TitleBar.tsx +++ b/src/browser/components/TitleBar/TitleBar.tsx @@ -187,7 +187,11 @@ export function TitleBar(props: TitleBarProps) { return ; } - if (updateStatus.type === "downloading" || updateStatus.type === "checking") { + if ( + updateStatus.type === "downloading" || + updateStatus.type === "checking" || + updateStatus.type === "restarting" + ) { return ; } diff --git a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.stories.tsx b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.stories.tsx new file mode 100644 index 00000000000..b8679948e4b --- /dev/null +++ b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "@storybook/test"; +import { APIProvider } from "@/browser/contexts/API"; +import { lightweightMeta } from "@/browser/stories/meta"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { UpdateRestartOverlay } from "./UpdateRestartOverlay"; + +function RestartOverlayStory() { + const [client] = useState(() => + createMockORPCClient({ updateStatus: { type: "restarting", info: { version: "0.29.0" } } }) + ); + return ( + +
+

Workspace content

+

+ Everything here must stay hidden behind the restart screen. +

+
+ +
+ ); +} + +const meta: Meta = { + ...lightweightMeta, + title: "Components/UpdateRestartOverlay", + component: RestartOverlayStory, +}; +export default meta; +type Story = StoryObj; + +async function expectOverlayCoversViewport() { + const overlay = await within(document.body).findByTestId("update-restart-overlay"); + await expect(within(overlay).getByText("Restarting Xum…")).toBeVisible(); + const rect = overlay.getBoundingClientRect(); + await expect(rect.top).toBeLessThanOrEqual(0); + await expect(rect.left).toBeLessThanOrEqual(0); + await expect(rect.right).toBeGreaterThanOrEqual(window.innerWidth); + await expect(rect.bottom).toBeGreaterThanOrEqual(window.innerHeight); +} + +export const Restarting: Story = { + play: expectOverlayCoversViewport, +}; + +export const RestartingPhone: Story = { + parameters: { pixel: { matrix: { viewports: ["phone"] } } }, + globals: { viewport: { value: "mobile1", isRotated: false } }, + play: expectOverlayCoversViewport, +}; diff --git a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.test.tsx b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.test.tsx new file mode 100644 index 00000000000..ef5259fdaba --- /dev/null +++ b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.test.tsx @@ -0,0 +1,159 @@ +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import type { UpdateStatus } from "@/common/orpc/types"; +import type * as APIModule from "@/browser/contexts/API"; +import type { APIClient } from "@/browser/contexts/API"; +import { installDom } from "../../../../tests/ui/dom"; +import { ThemeProvider } from "../../contexts/ThemeContext"; + +// SVG ?react imports don't work in happy-dom; stub them as simple svgs. +void mock.module("@/browser/assets/logos/xum-logo-dark.svg?react", () => ({ + __esModule: true, + default: () => , +})); +void mock.module("@/browser/assets/logos/xum-logo-light.svg?react", () => ({ + __esModule: true, + default: () => , +})); + +/** Push-driven stand-in for the update.onStatus subscription. */ +function createStatusStream() { + const queue: UpdateStatus[] = []; + let wake: (() => void) | null = null; + const onStatus = async function* (_input: undefined, options: { signal: AbortSignal }) { + while (!options.signal.aborted) { + const next = queue.shift(); + if (next) { + yield next; + continue; + } + await new Promise((resolve) => { + wake = resolve; + options.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + } + }; + return { + api: { update: { onStatus } } as unknown as APIClient, + push(status: UpdateStatus) { + queue.push(status); + wake?.(); + wake = null; + }, + }; +} + +let apiState: { api: APIClient | null; status: "connected" | "reconnecting" } = { + api: null, + status: "reconnecting", +}; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const actualAPI = require("@/browser/contexts/API?real=1") as typeof APIModule; +/* eslint-enable @typescript-eslint/no-require-imports */ + +// Spread the real module: replacing it outright deletes exports other test files import +// statically (module mocks are process-wide and persist across files). +void mock.module("@/browser/contexts/API", () => ({ + ...actualAPI, + useAPI: () => ({ + api: apiState.api, + status: apiState.status, + error: null, + attempt: 1, + authenticate: () => undefined, + retry: () => undefined, + }), +})); + +import type { UpdateRestartOverlay as UpdateRestartOverlayComponent } from "./UpdateRestartOverlay"; + +// Required after the mocks above so the svg stubs are in place when LoadingScreen evaluates. +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */ +const { + UpdateRestartOverlay, +}: { + UpdateRestartOverlay: typeof UpdateRestartOverlayComponent; +} = require("./UpdateRestartOverlay"); +/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */ + +const OVERLAY = "update-restart-overlay"; + +function renderOverlay() { + return render( + + + + ); +} + +async function flushStream() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +let cleanupDom: (() => void) | null = null; + +describe("UpdateRestartOverlay", () => { + beforeEach(() => { + cleanupDom = installDom(); + apiState = { api: null, status: "reconnecting" }; + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("covers the app from the restarting status until a relaunched server reports otherwise", async () => { + const before = createStatusStream(); + apiState = { api: before.api, status: "connected" }; + const view = renderOverlay(); + + before.push({ type: "downloaded", info: { version: "0.29.0" } }); + await flushStream(); + expect(view.queryByTestId(OVERLAY)).toBeNull(); + + before.push({ type: "restarting", info: { version: "0.29.0" } }); + await waitFor(() => expect(view.getByTestId(OVERLAY)).toBeTruthy()); + + // The server goes away while it restarts: the client has no api during reconnects. + apiState = { api: null, status: "reconnecting" }; + view.rerender( + + + + ); + await flushStream(); + expect(view.getByTestId(OVERLAY)).toBeTruthy(); + + // The relaunched server's first status clears the cover. + const after = createStatusStream(); + apiState = { api: after.api, status: "connected" }; + view.rerender( + + + + ); + await flushStream(); + expect(view.getByTestId(OVERLAY)).toBeTruthy(); + after.push({ type: "idle" }); + await waitFor(() => expect(view.queryByTestId(OVERLAY)).toBeNull()); + }); + + test("hides again when the install fails after restarting was announced", async () => { + const stream = createStatusStream(); + apiState = { api: stream.api, status: "connected" }; + const view = renderOverlay(); + + stream.push({ type: "restarting", info: { version: "0.29.0" } }); + await waitFor(() => expect(view.getByTestId(OVERLAY)).toBeTruthy()); + + stream.push({ type: "error", phase: "install", message: "activation failed" }); + await waitFor(() => expect(view.queryByTestId(OVERLAY)).toBeNull()); + }); +}); diff --git a/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx new file mode 100644 index 00000000000..7244152473c --- /dev/null +++ b/src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen"; + +/** + * Full-viewport cover shown from the moment an update install is confirmed (status + * "restarting") until the relaunched backend reports a different status. It replaces the old + * UI immediately, while the desktop app is still quitting or the server is tearing down. + */ +export function UpdateRestartOverlay() { + const { api } = useAPI(); + const [restarting, setRestarting] = useState(false); + + useEffect(() => { + // Deliberately no reset when api is null: the client passes through "reconnecting" with + // api === null while the server restarts, and the overlay must stay up until the relaunched + // server's first status event (idle/unsupported) replaces it. + if (!api) { + return; + } + + const controller = new AbortController(); + const { signal } = controller; + + (async () => { + try { + const iterator = await api.update.onStatus(undefined, { signal }); + for await (const status of iterator) { + if (signal.aborted) { + break; + } + setRestarting(status.type === "restarting"); + } + } catch (error) { + if (!signal.aborted) { + console.error("Update status stream error:", error); + } + } + })(); + + return () => { + controller.abort(); + }; + }, [api]); + + if (!restarting) { + return null; + } + + // Stacked above dialogs, toasts, and menus so nothing from the old UI peeks through. + return ( +
+ +
+ ); +} diff --git a/src/browser/features/About/AboutDialog.stories.tsx b/src/browser/features/About/AboutDialog.stories.tsx index c40297afb5d..3e535cda281 100644 --- a/src/browser/features/About/AboutDialog.stories.tsx +++ b/src/browser/features/About/AboutDialog.stories.tsx @@ -75,6 +75,19 @@ export const Downloading: Story = { }, }; +export const Restarting: 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(); + }, +}; + export const BlockedPhone: Story = { args: { status: { diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index d915c0564af..ba1472cf59e 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -160,6 +160,7 @@ export function AboutDialog() { (updateStatus.type === "checking" || updateStatus.type === "downloading" || pendingAction === "check"); + const isRestarting = updateStatus.type === "restarting"; const handleChannelChange = (next: UpdateChannel) => { if (!api || next === channel || channelLoading) { @@ -260,7 +261,9 @@ export function AboutDialog() { handleChannelChange(next); } }} - disabled={channelLoading || isChecking || pendingAction !== null} + disabled={ + channelLoading || isChecking || isRestarting || pendingAction !== null + } aria-label="Update channel" size="sm" > @@ -278,7 +281,7 @@ export function AboutDialog() {