From 03033727c6b2f6ab9838d0997bd4a3790a61cae2 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:09:24 +0000 Subject: [PATCH 1/3] feat(desktop): download updates in background and install on quit --- apps/desktop/src/app/DesktopLifecycle.test.ts | 17 +- apps/desktop/src/app/DesktopLifecycle.ts | 13 +- apps/desktop/src/electron/ElectronApp.test.ts | 29 ++ apps/desktop/src/electron/ElectronUpdater.ts | 10 + .../src/ipc/methods/localEnvironment.test.ts | 2 + apps/desktop/src/ipc/methods/wsl.test.ts | 5 + .../src/updates/DesktopUpdates.test.ts | 270 +++++++++++++++--- apps/desktop/src/updates/DesktopUpdates.ts | 99 ++++++- .../desktop/src/updates/updatesTestHarness.ts | 22 +- .../sidebar/DesktopUpdateStatusIcon.tsx | 3 + .../SidebarUpdatePill.browser.test.tsx | 14 + .../components/sidebar/SidebarUpdatePill.tsx | 1 + docs/operations/release.md | 4 +- docs/user/updating.md | 5 + 14 files changed, 438 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index fa1bcd8bbf05a..a12ad2a1e5d87 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -11,6 +11,7 @@ import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; @@ -226,9 +227,10 @@ describe("DesktopLifecycle", () => { }), ); - it.effect("relaunches without the app link that launched it", () => + it.effect("disarms updates and drops startup app links on relaunch", () => Effect.gen(function* () { const relaunched = yield* Deferred.make(); + let armed = true; const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform: "linux", isDevelopment: false, @@ -239,9 +241,20 @@ describe("DesktopLifecycle", () => { new Map(), Effect.void, () => Effect.void, - (options) => Deferred.succeed(relaunched, options).pipe(Effect.asVoid), + (options) => + Effect.sync(() => { + assert.isFalse(armed); + }).pipe(Effect.andThen(Deferred.succeed(relaunched, options)), Effect.asVoid), ), ), + Layer.provideMerge( + Layer.mock(ElectronUpdater.ElectronUpdater, { + setAutoInstallOnAppQuit: (value) => + Effect.sync(() => { + armed = value; + }), + }), + ), Layer.provideMerge(layerElectronTheme), Layer.provideMerge(layerElectronWindow()), Layer.provideMerge(layerDesktopWindow()), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 0b5039bd623df..d4a9bd57593c4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -17,6 +17,7 @@ import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -46,14 +47,18 @@ type DesktopLifecycleRegistrationServices = | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronUpdater | ElectronWindow */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, { readonly relaunch: ( reason: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + never, + DesktopLifecycleRuntimeServices | ElectronUpdater.ElectronUpdater + >; readonly register: Effect.Effect< void, never, @@ -217,6 +222,7 @@ export const make = DesktopLifecycle.of({ const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; const state = yield* DesktopState.DesktopState; + const electronUpdater = yield* ElectronUpdater.ElectronUpdater; yield* logLifecycleInfo("desktop relaunch requested", { reason }); yield* Effect.gen(function* () { yield* Effect.yieldNow; @@ -226,6 +232,9 @@ export const make = DesktopLifecycle.of({ yield* electronApp.exit(75); return; } + // app.exit still emits "quit", where electron-updater would install a + // downloaded update under the relaunched app on Windows and Linux. + yield* electronUpdater.setAutoInstallOnAppQuit(false); // Startup already acted on the app link that launched it; passing it on replays it. const appLinkPrefix = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}:`; const args = yield* HostProcess.Arguments; diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 30b7f2d7387e3..4242b55e221e8 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -8,9 +8,19 @@ const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({ getUserDefaultMock: vi.fn((_key: string) => false), })); +const { appEvents } = await vi.hoisted(async () => { + const { EventEmitter } = await import("node:events"); + return { appEvents: new EventEmitter() }; +}); +const { autoUpdaterMock } = vi.hoisted(() => ({ + autoUpdaterMock: { autoInstallOnAppQuit: false }, +})); +vi.mock("electron-updater", () => ({ autoUpdater: autoUpdaterMock })); + vi.mock("electron", () => ({ app: { getSystemLocale: getSystemLocaleMock, + once: appEvents.once.bind(appEvents), }, systemPreferences: { getUserDefault: getUserDefaultMock, @@ -18,6 +28,7 @@ vi.mock("electron", () => ({ })); import * as ElectronApp from "./ElectronApp.ts"; +import * as ElectronUpdater from "./ElectronUpdater.ts"; describe("ElectronApp", () => { beforeEach(() => { @@ -87,3 +98,21 @@ it.effect("reads both macOS hour preferences without overriding explicit formats } }), ); + +it.effect("records only an armed normal quit after the updater scope closes", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + const recordInstall = vi.fn(); + for (const [exitCode, armed, expectedRecords] of [ + [1, true, 0], + [0, false, 0], + [0, true, 1], + ] as const) { + yield* updater.setAutoInstallOnAppQuit(true); + yield* Effect.scoped(updater.onInstallOnAppQuit(recordInstall)); + yield* updater.setAutoInstallOnAppQuit(armed); + appEvents.emit("quit", {}, exitCode); + assert.equal(recordInstall.mock.calls.length, expectedRecords); + } + }).pipe(Effect.provide(ElectronUpdater.layer)), +); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index c71b42040db29..d98407607780c 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Electron from "electron"; import { autoUpdater } from "electron-updater"; type AutoUpdater = typeof autoUpdater; @@ -61,6 +62,7 @@ export class ElectronUpdater extends Context.Service< readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; readonly setAutoDownload: (value: boolean) => Effect.Effect; readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly onInstallOnAppQuit: (listener: () => void) => Effect.Effect; readonly setChannel: (channel: string) => Effect.Effect; readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; @@ -100,6 +102,14 @@ export const make = ElectronUpdater.of({ return Effect.void; }); }), + onInstallOnAppQuit: Effect.fn("ElectronUpdater.onInstallOnAppQuit")(function (listener) { + return Effect.sync(() => { + // Shutdown closes the service scope before electron-updater's final quit listener runs. + Electron.app.once("quit", (_event, exitCode) => { + if (exitCode === 0 && autoUpdater.autoInstallOnAppQuit) listener(); + }); + }); + }), setChannel: Effect.fn("ElectronUpdater.setChannel")(function (channel) { return Effect.suspend(() => { autoUpdater.channel = channel; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index 94bbf085a9921..121d40e470464 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -8,6 +8,7 @@ import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronUpdater from "../../electron/ElectronUpdater.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; @@ -24,6 +25,7 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( Layer.mock(DesktopWindow.DesktopWindow, {}), Layer.mock(ElectronApp.ElectronApp, {}), Layer.mock(ElectronTheme.ElectronTheme, {}), + Layer.mock(ElectronUpdater.ElectronUpdater, {}), ); describe("local environment IPC", () => { diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index af42c7264748d..bf30a5b028c80 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -11,6 +11,7 @@ import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronUpdater from "../../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; @@ -72,6 +73,10 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronUpdater.ElectronUpdater, + ElectronUpdater.ElectronUpdater.of({} as ElectronUpdater.ElectronUpdater["Service"]), + ), Layer.succeed( ElectronDialog.ElectronDialog, ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..0d8a72e1ffe43 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -12,6 +12,17 @@ import * as References from "effect/References"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; + +const { mkdirSyncMock, writeFileSyncMock } = vi.hoisted(() => ({ + mkdirSyncMock: vi.fn(), + writeFileSyncMock: vi.fn(), +})); +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + mkdirSync: mkdirSyncMock, + writeFileSync: writeFileSyncMock, +})); import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -321,45 +332,46 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("restores download state and permits retry after interruption", () => - Effect.gen(function* () { - const actionStarted = yield* Deferred.make(); - let disableDifferentialCalls = 0; - const harness = makeHarness({ - setDisableDifferentialDownload: Effect.suspend(() => { - disableDifferentialCalls += 1; - if (disableDifferentialCalls === 1) { - return Effect.void; - } - if (disableDifferentialCalls === 2) { - return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); - } - return Effect.void; - }), - }); + it.effect("keeps a canceled request's native download eligible for normal quit", () => { + writeFileSyncMock.mockReset(); + const downloadStarted = Deferred.makeUnsafe(); + const nativeDownload = Promise.withResolvers(); + const harness = makeHarness({ + platform: "win32", + downloadUpdate: Deferred.succeed(downloadStarted, undefined).pipe( + Effect.andThen(Effect.promise(() => nativeDownload.promise)), + ), + }); - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; - const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); - yield* Deferred.await(actionStarted); - yield* Fiber.interrupt(downloadFiber); + const request = yield* updates.download.pipe(Effect.forkScoped); + yield* Deferred.await(downloadStarted); + yield* Fiber.interrupt(request); + const reservedAfterCancellation = yield* updates.isActionActive; - const interruptedState = yield* updates.getState; - assert.equal(interruptedState.status, "available"); - assert.isNull(interruptedState.message); + harness.emit("update-downloaded", { version: "1.2.4" }); + nativeDownload.resolve(); + yield* flushCallbacks; - const retry = yield* updates.download; - assert.isTrue(retry.accepted); - assert.isTrue(retry.completed); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); + assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); + harness.emit("quit", {}, 0); + assert.equal(writeFileSyncMock.mock.calls.length, 1); + assert.deepEqual(JSON.parse(String(writeFileSyncMock.mock.calls[0]![1])), { + fromVersion: "1.2.3", + targetVersion: "1.2.4", + }); + assert.isTrue(reservedAfterCancellation); + assert.isFalse(yield* updates.isActionActive); + assert.equal(harness.quitAndInstalls(), 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); it.effect("clears quitting state after an unexpected install setup failure", () => { const harness = makeHarness({ @@ -502,6 +514,7 @@ describe("DesktopUpdates", () => { it.effect("keeps windows and restarts backends when quitAndInstall fails", () => { const harness = makeHarness({ + platform: "win32", quitAndInstall: Effect.fail( new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ channel: "latest", @@ -517,12 +530,17 @@ describe("DesktopUpdates", () => { const desktopState = yield* DesktopState.DesktopState; const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + yield* updates.download; + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), true); harness.emit("update-downloaded", { version: "1.2.4" }); yield* flushCallbacks; const result = yield* updates.install; assert.isTrue(result.accepted); assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), false); assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); assert.equal(harness.updateRestartMarkers.size, 0); assert.equal(harness.installRecords.size, 0); @@ -697,12 +715,16 @@ describe("DesktopUpdates", () => { }); it.effect("ignores a finished download from a track it no longer follows", () => { - const harness = makeHarness(); + const harness = makeHarness({ platform: "win32" }); return Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + yield* updates.download; + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), true); // A backed-out switch's download keeps reporting progress until it finishes. harness.emit("download-progress", { percent: 40 }); yield* flushCallbacks; @@ -712,6 +734,9 @@ describe("DesktopUpdates", () => { const state = yield* updates.getState; assert.equal(state.downloadedVersion, null); assert.notEqual(state.status, "downloading"); + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), false); + yield* TestClock.adjust("4 minutes"); + assert.equal(harness.checkCount(), 2); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -917,6 +942,179 @@ describe("DesktopUpdates", () => { ); }); +/** A harness whose update feed announces `feed.version` on every check. */ +function makeFeedHarness( + options: Parameters[0] = {}, + feed: { version: string | null } = { version: "1.2.4" }, +) { + let emit: ReturnType["emit"] | undefined; + const harness = makeHarness({ + platform: "win32", + ...options, + checkForUpdates: Effect.sync(() => { + if (feed.version === null) emit?.("update-not-available"); + else emit?.("update-available", { version: feed.version }); + }), + }); + emit = harness.emit; + return { harness, feed }; +} + +const failDownload = Effect.fail( + new ElectronUpdater.ElectronUpdaterDownloadUpdateError({ + channel: null, + cause: new Error("blocked"), + }), +); + +describe("DesktopUpdates background downloads", () => { + it.effect.each(["win32", "linux"] as const)( + "keeps a slow startup download through polls and reports an unapplied quit install on %s", + (platform) => { + writeFileSyncMock.mockReset(); + const releaseDownload = Deferred.makeUnsafe(); + const { harness, feed } = makeFeedHarness({ + platform, + env: { APPIMAGE: "/tmp/T3Code.AppImage" }, + downloadUpdate: Deferred.await(releaseDownload), + }); + + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(harness.downloadCount(), 1); + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), true); + + yield* TestClock.adjust(Duration.minutes(4)); + assert.equal((yield* updates.getState).status, "downloading"); + assert.isFalse((yield* updates.check("manual")).checked); + assert.equal(harness.downloadCount(), 1); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* Deferred.succeed(releaseDownload, undefined); + yield* flushCallbacks; + feed.version = "1.2.5"; + yield* TestClock.adjust(Duration.minutes(4)); + assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); + assert.equal(harness.checkCount(), 1); + assert.equal(harness.downloadCount(), 1); + + // A manual check can clear UI state while the old installer remains queued. + yield* updates.check("manual"); + assert.equal((yield* updates.getState).availableVersion, "1.2.5"); + assert.isNull((yield* updates.getState).downloadedVersion); + yield* TestClock.adjust(Duration.minutes(4)); + assert.equal(harness.checkCount(), 2); + assert.equal(harness.downloadCount(), 1); + assert.equal(writeFileSyncMock.mock.calls.length, 0); + }), + ).pipe(Effect.provide(harness.layer)); + + harness.emit("quit", {}, 0); + assert.equal(writeFileSyncMock.mock.calls.length, 1); + const [recordPath, installRecord] = writeFileSyncMock.mock.calls[0]!; + assert.isTrue( + String(recordPath).endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE), + ); + assert.deepEqual(JSON.parse(String(installRecord)), { + fromVersion: "1.2.3", + targetVersion: "1.2.4", + }); + + const restarted = makeFeedHarness({ + platform, + installRecord: String(installRecord), + env: { APPIMAGE: "/tmp/T3Code.AppImage" }, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust(Duration.minutes(5)); + assert.equal((yield* updates.getState).notAppliedVersion, "1.2.4"); + assert.equal(restarted.harness.downloadCount(), 0); + assert.isTrue((yield* updates.download).accepted); + assert.equal(restarted.harness.downloadCount(), 1); + }), + ).pipe(Effect.provide(restarted.harness.layer)); + }).pipe(Effect.provide(TestClock.layer())); + }, + ); + + it.effect("keeps manual Download and disarms its installer when the channel changes", () => { + const { harness } = makeFeedHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const result = yield* updates.check("manual"); + assert.equal(result.state.status, "available"); + assert.equal(harness.downloadCount(), 0); + + assert.isTrue((yield* updates.download).completed); + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), true); + + const state = yield* updates.setChannel("nightly"); + assert.isNull(state.downloadedVersion); + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), false); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect.each(["darwin", "linux"] as const)("keeps explicit Install on %s", (platform) => { + const { harness } = makeFeedHarness({ platform, packageType: "deb" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust(Duration.seconds(15)); + + assert.equal(harness.downloadCount(), 1); + assert.notInclude(harness.autoInstallOnAppQuitCalls, true); + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + assert.isTrue((yield* updates.install).accepted); + assert.equal(harness.quitAndInstalls(), 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect( + "disarms a failed download, skips background retries, and permits manual or newer retries", + () => { + const { harness, feed } = makeFeedHarness({ downloadUpdate: failDownload }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(harness.downloadCount(), 1); + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), false); + + yield* TestClock.adjust(Duration.minutes(8)); + assert.equal(harness.checkCount(), 3); + assert.equal(harness.downloadCount(), 1); + + assert.isTrue((yield* updates.download).accepted); + assert.equal(harness.downloadCount(), 2); + feed.version = "1.2.5"; + yield* TestClock.adjust(Duration.minutes(4)); + assert.equal(harness.downloadCount(), 3); + assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), false); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }, + ); +}); + describe("describeUpdateErrorCause", () => { it("removes URLs, credentials, response bodies, and headers from summaries", () => { const messages = [ diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..5e25ed58d599d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Electron's final quit event cannot await asynchronous filesystem writes. +import * as NodeFS from "node:fs"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE, DesktopUpdateChannelSchema, @@ -14,6 +16,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -362,6 +365,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean /** @public */ export const make = Effect.gen(function* () { + const scope = yield* Scope.Scope; const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; const desktopState = yield* DesktopState.DesktopState; @@ -382,6 +386,10 @@ export const make = Effect.gen(function* () { const finishedUpdateActions = yield* PubSub.unbounded(); const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); + // Pollers must not retry a persistently failing download every four minutes. + const failedDownloads = new Set(); + // Manual checks can clear downloadedVersion while the native installer stays queued. + let downloadedInstallerVersion: string | null = null; const notAppliedVersionRef = yield* Ref.make(Option.none()); // The release a track switch's database check cleared. A build crosses to another track only // through such a switch, and never below that release: version order alone cannot tell whether @@ -587,28 +595,50 @@ export const make = Effect.gen(function* () { ); }); - const downloadAvailableUpdate = Effect.gen(function* () { + const downloadAvailableUpdate = Effect.fn("desktop.updates.downloadAvailableUpdate")(function* ( + actionReservation: "acquire" | "held" = "acquire", + ) { const state = yield* Ref.get(updateStateRef); - if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") { + if ( + (yield* Ref.get(desktopState.quitting)) || + !(yield* Ref.get(updaterConfiguredRef)) || + state.status !== "available" + ) { return { accepted: false, completed: false }; } - if (!(yield* tryStartUpdateAction("download"))) { + if (actionReservation === "held") { + yield* Ref.set(activeUpdateActionRef, Option.some("download")); + } else if (!(yield* tryStartUpdateAction("download"))) { return { accepted: false, completed: false }; } + const releaseFailedDownload = Effect.sync(() => { + downloadedInstallerVersion = null; + if (state.availableVersion !== null) failedDownloads.add(state.availableVersion); + }).pipe(Effect.andThen(electronUpdater.setAutoInstallOnAppQuit(false))); + return yield* Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), ); yield* logUpdaterInfo("downloading update"); + // Squirrel stages macOS updates irreversibly when this flag is true. + // Keep staging behind Install; .deb installs also need an explicit action. + yield* electronUpdater.setAutoInstallOnAppQuit( + environment.platform !== "darwin" && !isDebPackage, + ); + downloadedInstallerVersion = null; yield* electronUpdater.downloadUpdate; + downloadedInstallerVersion ??= state.availableVersion; + if (downloadedInstallerVersion !== null) failedDownloads.delete(downloadedInstallerVersion); return { accepted: true, completed: true }; }).pipe( Effect.catchTags({ ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( function* (error) { + yield* releaseFailedDownload; yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); @@ -622,9 +652,14 @@ export const make = Effect.gen(function* () { ), }), Effect.onInterrupt(() => - updateState((current) => (current.status === "downloading" ? state : current)).pipe( - Effect.asVoid, - ), + electronUpdater + .setAutoInstallOnAppQuit(false) + .pipe( + Effect.andThen( + updateState((current) => (current.status === "downloading" ? state : current)), + ), + Effect.asVoid, + ), ), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -632,6 +667,7 @@ export const make = Effect.gen(function* () { } const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); return Effect.gen(function* () { + yield* releaseFailedDownload; yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); @@ -644,8 +680,27 @@ export const make = Effect.gen(function* () { }); }), Effect.ensuring(finishUpdateAction("download")), + // Native downloads outlive request cancellation. Keep their reservation and + // quit-install flag until completion or service shutdown. + Effect.forkIn(scope), + Effect.flatMap(Fiber.join), ); - }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + }); + + const checkAndDownloadInBackground = Effect.fn("desktop.updates.checkAndDownloadInBackground")( + function* (reason: "startup" | "poll") { + if (downloadedInstallerVersion !== null || !(yield* tryStartUpdateAction("check"))) return; + yield* Effect.gen(function* () { + if (!(yield* checkForUpdates(reason, "held"))) return; + const version = (yield* Ref.get(updateStateRef)).availableVersion; + if (version === null || failedDownloads.has(version)) return; + const notApplied = yield* Ref.get(notAppliedVersionRef); + if (Option.isSome(notApplied) && notApplied.value === version) return; + // Retain the reservation so another check or channel change cannot replace this release. + yield* downloadAvailableUpdate("held"); + }).pipe(Effect.ensuring(finishUpdateAction("check"))); + }, + ); const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime"); const updateRestartMarkerPath = environment.path.join( @@ -712,6 +767,7 @@ export const make = Effect.gen(function* () { const resetInstallAction = Effect.all( [ + electronUpdater.setAutoInstallOnAppQuit(false), finishUpdateAction("install"), Ref.set(desktopState.quitting, false), Ref.set(installRunningBackendsRef, []), @@ -731,6 +787,7 @@ export const make = Effect.gen(function* () { ); if (!ownsRecovery) return; + yield* electronUpdater.setAutoInstallOnAppQuit(false); yield* Ref.set(desktopState.quitting, false); yield* removeUpdateRestartMarker; yield* removeInstallRecord; @@ -885,7 +942,7 @@ export const make = Effect.gen(function* () { const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( - Effect.andThen(checkForUpdates("startup")), + Effect.andThen(checkAndDownloadInBackground("startup")), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.void; @@ -900,7 +957,7 @@ export const make = Effect.gen(function* () { Effect.forkScoped, ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( - Effect.andThen(checkForUpdates("poll")), + Effect.andThen(checkAndDownloadInBackground("poll")), Effect.forever, Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1064,6 +1121,8 @@ export const make = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); // A download can finish after its track switch moved on; never offer that build. if (!(yield* offerable(info.version, state))) { + yield* electronUpdater.setAutoInstallOnAppQuit(false); + downloadedInstallerVersion = null; yield* logUpdaterInfo("ignoring download outside the selected channel or its floor", { version: info.version, channel: state.channel, @@ -1076,6 +1135,7 @@ export const make = Effect.gen(function* () { } return; } + downloadedInstallerVersion = info.version; yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), @@ -1157,6 +1217,23 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setAutoDownload(false); yield* electronUpdater.setAutoInstallOnAppQuit(false); + // Register before downloading: electron-updater adds its synchronous + // installer to this same event only after a download completes. + yield* electronUpdater.onInstallOnAppQuit(() => { + if (downloadedInstallerVersion === null) return; + try { + NodeFS.mkdirSync(updateRestartMarkerDir, { recursive: true }); + NodeFS.writeFileSync( + installRecordPath, + JSON.stringify({ + fromVersion: environment.appVersion, + targetVersion: downloadedInstallerVersion, + }), + ); + } catch { + // A failed record must not prevent the updater's quit listener. + } + }); yield* applyAutoUpdaterChannel(settings.updateChannel); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), @@ -1241,6 +1318,8 @@ export const make = Effect.gen(function* () { ), ); + yield* electronUpdater.setAutoInstallOnAppQuit(false); + downloadedInstallerVersion = null; const disabledReason = yield* resolveDisabledReason; yield* setState(yield* baseUpdateState(nextChannel, disabledReason)); @@ -1276,7 +1355,7 @@ export const make = Effect.gen(function* () { }; }), download: Effect.gen(function* () { - const result = yield* downloadAvailableUpdate; + const result = yield* downloadAvailableUpdate(); return { accepted: result.accepted, completed: result.completed, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 226f00f8cba89..ff757efd4a21a 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -27,7 +27,7 @@ export interface UpdatesHarnessOptions { readonly beforeSetUpdateChannel?: Effect.Effect; readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; - readonly downloadUpdate?: Effect.Effect; + readonly downloadUpdate?: Effect.Effect; readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; @@ -52,6 +52,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let allowDowngrade = false; const checkAllowedDowngrade: boolean[] = []; let fullChangelog = false; + let installOnAppQuit: (() => void) | undefined; + const autoInstallOnAppQuitCalls: boolean[] = []; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -80,7 +82,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { feedUrls.push(options); }), setAutoDownload: () => Effect.void, - setAutoInstallOnAppQuit: () => Effect.void, + setAutoInstallOnAppQuit: (value) => + Effect.sync(() => { + autoInstallOnAppQuitCalls.push(value); + }), + onInstallOnAppQuit: (listener) => + Effect.sync(() => { + installOnAppQuit = listener; + }), setChannel: () => Effect.void, setAllowPrerelease: () => Effect.void, allowDowngrade: Effect.sync(() => allowDowngrade), @@ -306,15 +315,20 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { /** Whether each feed check could offer an older release, and the flag now. */ checkAllowedDowngrade, allowDowngrade: () => allowDowngrade, + /** Every value passed to setAutoInstallOnAppQuit, in order. */ + autoInstallOnAppQuitCalls, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, 0, ), sentStates, - emit: (eventName: string, payload?: unknown) => { + emit: (eventName: string, ...args: ReadonlyArray) => { + if (eventName === "quit" && args[1] === 0 && autoInstallOnAppQuitCalls.at(-1)) { + installOnAppQuit?.(); + } for (const listener of listeners.get(eventName) ?? []) { - listener(payload); + listener(...args); } }, }; diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 9b8b388044f55..1c0ca3f12827d 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -19,14 +19,17 @@ function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { } export function shouldShowDesktopUpdateCheckIcon({ + hasUpdateAction, isAnimationLatched, isChecking, prefersReducedMotion, }: { + readonly hasUpdateAction: boolean; readonly isAnimationLatched: boolean; readonly isChecking: boolean; readonly prefersReducedMotion: boolean; }): boolean { + if (hasUpdateAction) return false; return isChecking || (isAnimationLatched && !prefersReducedMotion); } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 03bb2c72849e2..682318e2f5b11 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -141,3 +141,17 @@ describe("SidebarUpdatePill update everything", () => { await vi.waitFor(() => expect(document.activeElement).toBe(button.element())); }); }); + +it("shows the update action as soon as a check finds a release", async () => { + setup({ ...nightlyUpdate([]), status: "checking", availableVersion: null }); + await using screen = await render(); + await expect + .element(screen.getByRole("button", { name: "Checking for updates…" })) + .toBeDisabled(); + + await publish(nightlyUpdate([])); + await expect.element(screen.getByRole("button", { name: /ready to download/ })).toBeEnabled(); + + await publish({ ...nightlyUpdate([]), status: "downloading", downloadPercent: 50 }); + await expect.element(screen.getByRole("button", { name: /Downloading/ })).toBeVisible(); +}); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 6871b9149fd48..c72f36b3e6ab3 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -234,6 +234,7 @@ function SidebarUpdateControl() { const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ + hasUpdateAction: action !== "none" || isDownloading, isAnimationLatched: isCheckAnimationLatched, isChecking: state?.status === "checking", prefersReducedMotion, diff --git a/docs/operations/release.md b/docs/operations/release.md index 4dd489bfde064..1bfe1cd826457 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -471,8 +471,8 @@ available. - `apps/desktop/src/main.ts` only wires the updater layers into the desktop runtime. - Update UX: - Background checks run on startup delay + interval. - - No automatic download or install. - - The desktop UI shows a rocket update button when an update is available; click once to download, click again after download to restart/install. + - Updates found by the startup and interval checks download automatically and install when the app quits. macOS and `.deb` installs are never armed for quit: Squirrel.Mac stages an armed update irreversibly, and dpkg asks for a password. They wait for **Install**. A version whose install did not apply is not downloaded again in the background. + - A manual check only reports; the update button downloads, then restarts to install right away. - Provider: GitHub Releases (`provider: github`) configured at build time. - Repository slug source: - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. diff --git a/docs/user/updating.md b/docs/user/updating.md index 6f8b35945cf6b..0b47ea03d654f 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -36,6 +36,11 @@ The confirmation counts the running threads that the restarts will interrupt, and how many can resume afterwards. See **Continue threads after restarts** below. +The desktop app downloads its own updates in the background and installs them +when you quit, except on macOS, where you choose **Install** to apply them. +Linux `.deb` updates ask for your password, so they wait until +you choose **Install**. + ## Switch between Stable and Nightly In the desktop app, change **Settings → About → Update track**. T3 Code shows each From 6571676aa294c3f12140a55bc35b0ebf98ea4bf0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 04:33:10 +0000 Subject: [PATCH 2/3] test(desktop): provide the updater to the startup test's relaunch --- apps/desktop/src/app/DesktopApp.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 66fc83adae194..5c7d1eb9cd1bd 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -65,6 +65,7 @@ import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as MacPermissions from "../permissions/MacPermissions.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -204,6 +205,7 @@ it.effect( Layer.mock(ElectronMenu.ElectronMenu, {}), Layer.mock(ElectronShell.ElectronShell, {}), Layer.mock(ElectronTheme.ElectronTheme, {}), + Layer.mock(ElectronUpdater.ElectronUpdater, {}), Layer.mock(ElectronWindow.ElectronWindow, {}), Layer.mock(MacPermissions.MacPermissions, {}), Layer.mock(PreviewManager.PreviewManager, { From 1d838c15dda37b06eb600fc1a09e1d6f215e5d10 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 05:25:58 +0000 Subject: [PATCH 3/3] fix(desktop): a rejected off-track download no longer stops background checks --- apps/desktop/src/updates/DesktopUpdates.test.ts | 12 ++++++++++-- apps/desktop/src/updates/DesktopUpdates.ts | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 0d8a72e1ffe43..ba069c8c71737 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -715,7 +715,11 @@ describe("DesktopUpdates", () => { }); it.effect("ignores a finished download from a track it no longer follows", () => { - const harness = makeHarness({ platform: "win32" }); + const releaseDownload = Deferred.makeUnsafe(); + const harness = makeHarness({ + platform: "win32", + downloadUpdate: Deferred.await(releaseDownload), + }); return Effect.scoped( Effect.gen(function* () { @@ -723,13 +727,17 @@ describe("DesktopUpdates", () => { yield* updates.configure; harness.emit("update-available", { version: "1.2.4" }); yield* flushCallbacks; - yield* updates.download; + const download = yield* Effect.forkChild(updates.download, { startImmediately: true }); + yield* flushCallbacks; assert.equal(harness.autoInstallOnAppQuitCalls.at(-1), true); // A backed-out switch's download keeps reporting progress until it finishes. harness.emit("download-progress", { percent: 40 }); yield* flushCallbacks; + // electron-updater emits update-downloaded before downloadUpdate resolves. harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); yield* flushCallbacks; + yield* Deferred.succeed(releaseDownload, undefined); + yield* Fiber.join(download); const state = yield* updates.getState; assert.equal(state.downloadedVersion, null); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 5e25ed58d599d..1dbe8461b33d6 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -390,6 +390,8 @@ export const make = Effect.gen(function* () { const failedDownloads = new Set(); // Manual checks can clear downloadedVersion while the native installer stays queued. let downloadedInstallerVersion: string | null = null; + // electron-updater emits update-downloaded before downloadUpdate resolves. + let downloadEventSeen = false; const notAppliedVersionRef = yield* Ref.make(Option.none()); // The release a track switch's database check cleared. A build crosses to another track only // through such a switch, and never below that release: version order alone cannot tell whether @@ -630,8 +632,10 @@ export const make = Effect.gen(function* () { environment.platform !== "darwin" && !isDebPackage, ); downloadedInstallerVersion = null; + downloadEventSeen = false; yield* electronUpdater.downloadUpdate; - downloadedInstallerVersion ??= state.availableVersion; + // The event handler may have rejected an off-track download; keep its decision. + if (!downloadEventSeen) downloadedInstallerVersion ??= state.availableVersion; if (downloadedInstallerVersion !== null) failedDownloads.delete(downloadedInstallerVersion); return { accepted: true, completed: true }; }).pipe( @@ -1118,6 +1122,7 @@ export const make = Effect.gen(function* () { yield* decodeUpdateInfo(raw).pipe( Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { + downloadEventSeen = true; const state = yield* Ref.get(updateStateRef); // A download can finish after its track switch moved on; never offer that build. if (!(yield* offerable(info.version, state))) {