From 89d8ace3dbafdc29f5cd193c5d1ac9f4c07db37a Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 28 Jul 2026 11:34:45 +0200 Subject: [PATCH] fix(desktop): allow updater-controlled relaunch --- .../src/app/DesktopAppIdentity.test.ts | 1 + apps/desktop/src/app/DesktopLifecycle.test.ts | 123 ++++++++++++++++++ apps/desktop/src/app/DesktopLifecycle.ts | 14 +- apps/desktop/src/electron/ElectronApp.test.ts | 28 ++++ apps/desktop/src/electron/ElectronApp.ts | 13 ++ .../src/window/DesktopApplicationMenu.test.ts | 1 + 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/DesktopLifecycle.test.ts diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 3c95b266bc1..50f82ac3026 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setDockIcon.push(iconPath); }), appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts new file mode 100644 index 00000000000..107ee21d232 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -0,0 +1,123 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +describe("DesktopLifecycle", () => { + for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { + it.effect(`lets the updater's quit event proceed on ${platform}`, () => { + const appListeners = new Map void>(); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + appListeners.delete("before-quit-for-update"); + }), + ).pipe(Effect.asVoid), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set( + eventName, + listener as unknown as (...args: readonly unknown[]) => void, + ); + }), + () => + Effect.sync(() => { + appListeners.delete(eventName); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronApp.ElectronApp["Service"]); + + const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }); + + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(electronAppLayer), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("before-quit-for-update")?.(); + + let prevented = false; + const event = { + preventDefault: () => { + prevented = true; + }, + } as Electron.Event; + appListeners.get("before-quit")?.(event); + + assert.isFalse( + prevented, + "cancelling this event prevents the updater from completing its relaunch", + ); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718..ab03d18f38d 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({ const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; + let updaterQuitAllowed = false; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), ); }); + yield* electronApp.onBeforeQuitForUpdate(() => { + // Electron's updater owns the remaining quit/install/relaunch sequence. + // Cancelling the following app "before-quit" event breaks that sequence, + // most visibly on macOS where the native updater performs the relaunch. + updaterQuitAllowed = true; + void runEffect( + logLifecycleInfo("allowing updater-controlled quit").pipe( + Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + ), + ); + }); yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( event, runEffect, - () => quitAllowed, + () => quitAllowed || updaterQuitAllowed, () => { quitAllowed = true; }, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f3ce3b4b5f4..077b343959c 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test"; const { appendSwitchMock, + autoUpdaterOnMock, + autoUpdaterRemoveListenerMock, exitMock, getAppPathMock, getVersionMock, @@ -23,6 +25,8 @@ const { whenReadyMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), + autoUpdaterOnMock: vi.fn(), + autoUpdaterRemoveListenerMock: vi.fn(), exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), getVersionMock: vi.fn(() => "1.2.3"), @@ -43,6 +47,10 @@ const { })); vi.mock("electron", () => ({ + autoUpdater: { + on: autoUpdaterOnMock, + removeListener: autoUpdaterRemoveListenerMock, + }, app: { commandLine: { appendSwitch: appendSwitchMock, @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts"; describe("ElectronApp", () => { beforeEach(() => { appendSwitchMock.mockClear(); + autoUpdaterOnMock.mockClear(); + autoUpdaterRemoveListenerMock.mockClear(); exitMock.mockClear(); onMock.mockClear(); quitMock.mockClear(); @@ -153,4 +163,22 @@ describe("ElectronApp", () => { assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("scopes native updater quit listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.onBeforeQuitForUpdate(listener); + }), + ); + + assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]); + assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [ + ["before-quit-for-update", listener], + ]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0af8691f6c4..933f40e1705 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service< readonly setDesktopName: (desktopName: string) => Effect.Effect; readonly setDockIcon: (iconPath: string) => Effect.Effect; readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly onBeforeQuitForUpdate: ( + listener: () => void, + ) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -178,6 +181,16 @@ export const make = ElectronApp.of({ } Electron.app.commandLine.appendSwitch(switchName, value); }), + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + Electron.autoUpdater.on("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + Electron.autoUpdater.removeListener("before-quit-for-update", listener); + }), + ).pipe(Effect.asVoid), on: addScopedAppListener, }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..0d48ab04ceb 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]);