authorgravatar for hi@4bs3nt.comjan <hi@4bs3nt.com> 2026-07-28 11:45:02+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 11:45:02+02:00
log9ccfd9dfef4e78285f9ac2a83b0333e67df36378
treea51901f064804df118aa75d3e987a4878d127d92
parent4f584da0f84666bda0cd399721115b0001cc0308
signature Signed by PGP key B5690EEEBB952194

fix(desktop): allow updater-controlled relaunch (#4721)


6 files changed, 179 insertions(+), 1 deletions(-)

apps/desktop/src/app/DesktopAppIdentity.test.ts+1
......@@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
6363 calls.setDockIcon.push(iconPath);
6464 }),
6565 appendCommandLineSwitch: () => Effect.void,
66 onBeforeQuitForUpdate: () => Effect.void,
6667 on: () => Effect.void,
6768 } satisfies ElectronApp.ElectronApp["Service"]);
6869
apps/desktop/src/app/DesktopLifecycle.test.ts created+123
......@@ -0,0 +1,123 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";
5
6import type * as Electron from "electron";
7
8import * as ElectronApp from "../electron/ElectronApp.ts";
9import * as ElectronTheme from "../electron/ElectronTheme.ts";
10import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11import * as DesktopLifecycle from "./DesktopLifecycle.ts";
12import * as DesktopShutdown from "./DesktopShutdown.ts";
13import * as DesktopState from "./DesktopState.ts";
14import * as DesktopWindow from "../window/DesktopWindow.ts";
15
16describe("DesktopLifecycle", () => {
17 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
18 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
19 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
20
21 const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
22 metadata: Effect.die("unexpected metadata read"),
23 name: Effect.succeed("T3 Code"),
24 whenReady: Effect.void,
25 quit: Effect.void,
26 exit: () => Effect.void,
27 relaunch: () => Effect.void,
28 setPath: () => Effect.void,
29 setName: () => Effect.void,
30 setAboutPanelOptions: () => Effect.void,
31 setAppUserModelId: () => Effect.void,
32 requestSingleInstanceLock: Effect.succeed(true),
33 isDefaultProtocolClient: () => Effect.succeed(false),
34 setAsDefaultProtocolClient: () => Effect.succeed(true),
35 setDesktopName: () => Effect.void,
36 setDockIcon: () => Effect.void,
37 appendCommandLineSwitch: () => Effect.void,
38 onBeforeQuitForUpdate: (listener) =>
39 Effect.acquireRelease(
40 Effect.sync(() => {
41 appListeners.set("before-quit-for-update", listener);
42 }),
43 () =>
44 Effect.sync(() => {
45 appListeners.delete("before-quit-for-update");
46 }),
47 ).pipe(Effect.asVoid),
48 on: (eventName, listener) =>
49 Effect.acquireRelease(
50 Effect.sync(() => {
51 appListeners.set(
52 eventName,
53 listener as unknown as (...args: readonly unknown[]) => void,
54 );
55 }),
56 () =>
57 Effect.sync(() => {
58 appListeners.delete(eventName);
59 }),
60 ).pipe(Effect.asVoid),
61 } satisfies ElectronApp.ElectronApp["Service"]);
62
63 const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
64 shouldUseDarkColors: Effect.succeed(false),
65 setSource: () => Effect.void,
66 onUpdated: () => Effect.void,
67 });
68
69 const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, {
70 createMain: Effect.die("unexpected window creation"),
71 ensureMain: Effect.die("unexpected window creation"),
72 revealOrCreateMain: Effect.die("unexpected window creation"),
73 activate: Effect.void,
74 createMainIfBackendReady: Effect.void,
75 showConnectingSplash: Effect.void,
76 handleBackendReady: () => Effect.void,
77 handleBackendNotReady: Effect.void,
78 flushMainWindowBounds: Effect.void,
79 dispatchMenuAction: () => Effect.void,
80 syncAppearance: Effect.void,
81 });
82
83 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
84 platform,
85 isDevelopment: false,
86 } as DesktopEnvironment.DesktopEnvironment["Service"]);
87
88 const layer = DesktopLifecycle.layer.pipe(
89 Layer.provideMerge(electronAppLayer),
90 Layer.provideMerge(electronThemeLayer),
91 Layer.provideMerge(desktopWindowLayer),
92 Layer.provideMerge(environmentLayer),
93 Layer.provideMerge(DesktopShutdown.layer),
94 Layer.provideMerge(DesktopState.layer),
95 );
96
97 return Effect.scoped(
98 Effect.gen(function* () {
99 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
100 yield* lifecycle.register;
101
102 appListeners.get("before-quit-for-update")?.();
103
104 let prevented = false;
105 const event = {
106 preventDefault: () => {
107 prevented = true;
108 },
109 } as Electron.Event;
110 appListeners.get("before-quit")?.(event);
111
112 assert.isFalse(
113 prevented,
114 "cancelling this event prevents the updater from completing its relaunch",
115 );
116
117 const state = yield* DesktopState.DesktopState;
118 assert.isTrue(yield* Ref.get(state.quitting));
119 }),
120 ).pipe(Effect.provide(layer));
121 });
122 }
123});
apps/desktop/src/app/DesktopLifecycle.ts+13-1
......@@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({
176176 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
177177 const runEffect = Effect.runPromiseWith(context);
178178 let quitAllowed = false;
179 let updaterQuitAllowed = false;
179180 yield* electronTheme.onUpdated(() => {
180181 void runEffect(
181182 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
182183 );
183184 });
185 yield* electronApp.onBeforeQuitForUpdate(() => {
186 // Electron's updater owns the remaining quit/install/relaunch sequence.
187 // Cancelling the following app "before-quit" event breaks that sequence,
188 // most visibly on macOS where the native updater performs the relaunch.
189 updaterQuitAllowed = true;
190 void runEffect(
191 logLifecycleInfo("allowing updater-controlled quit").pipe(
192 Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
193 ),
194 );
195 });
184196 yield* electronApp.on("before-quit", (event: Electron.Event) => {
185197 handleBeforeQuit(
186198 event,
187199 runEffect,
188 () => quitAllowed,
200 () => quitAllowed || updaterQuitAllowed,
189201 () => {
190202 quitAllowed = true;
191203 },
apps/desktop/src/electron/ElectronApp.test.ts+28
......@@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test";
44
55const {
66 appendSwitchMock,
7 autoUpdaterOnMock,
8 autoUpdaterRemoveListenerMock,
79 exitMock,
810 getAppPathMock,
911 getVersionMock,
......@@ -23,6 +25,8 @@ const {
2325 whenReadyMock,
2426} = vi.hoisted(() => ({
2527 appendSwitchMock: vi.fn(),
28 autoUpdaterOnMock: vi.fn(),
29 autoUpdaterRemoveListenerMock: vi.fn(),
2630 exitMock: vi.fn(),
2731 getAppPathMock: vi.fn(() => "/app"),
2832 getVersionMock: vi.fn(() => "1.2.3"),
......@@ -43,6 +47,10 @@ const {
4347}));
4448
4549vi.mock("electron", () => ({
50 autoUpdater: {
51 on: autoUpdaterOnMock,
52 removeListener: autoUpdaterRemoveListenerMock,
53 },
4654 app: {
4755 commandLine: {
4856 appendSwitch: appendSwitchMock,
......@@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts";
7785describe("ElectronApp", () => {
7886 beforeEach(() => {
7987 appendSwitchMock.mockClear();
88 autoUpdaterOnMock.mockClear();
89 autoUpdaterRemoveListenerMock.mockClear();
8090 exitMock.mockClear();
8191 onMock.mockClear();
8292 quitMock.mockClear();
......@@ -153,4 +163,22 @@ describe("ElectronApp", () => {
153163 assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);
154164 }).pipe(Effect.provide(ElectronApp.layer)),
155165 );
166
167 it.effect("scopes native updater quit listeners", () =>
168 Effect.gen(function* () {
169 const listener = vi.fn();
170
171 yield* Effect.scoped(
172 Effect.gen(function* () {
173 const electronApp = yield* ElectronApp.ElectronApp;
174 yield* electronApp.onBeforeQuitForUpdate(listener);
175 }),
176 );
177
178 assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]);
179 assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [
180 ["before-quit-for-update", listener],
181 ]);
182 }).pipe(Effect.provide(ElectronApp.layer)),
183 );
156184});
apps/desktop/src/electron/ElectronApp.ts+13
......@@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service<
6666 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
6767 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
6868 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
69 readonly onBeforeQuitForUpdate: (
70 listener: () => void,
71 ) => Effect.Effect<void, never, Scope.Scope>;
6972 readonly on: <Args extends ReadonlyArray<unknown>>(
7073 eventName: string,
7174 listener: (...args: Args) => void,
......@@ -178,6 +181,16 @@ export const make = ElectronApp.of({
178181 }
179182 Electron.app.commandLine.appendSwitch(switchName, value);
180183 }),
184 onBeforeQuitForUpdate: (listener) =>
185 Effect.acquireRelease(
186 Effect.sync(() => {
187 Electron.autoUpdater.on("before-quit-for-update", listener);
188 }),
189 () =>
190 Effect.sync(() => {
191 Electron.autoUpdater.removeListener("before-quit-for-update", listener);
192 }),
193 ).pipe(Effect.asVoid),
181194 on: addScopedAppListener,
182195});
183196
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
......@@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
4545 setDesktopName: () => Effect.void,
4646 setDockIcon: () => Effect.void,
4747 appendCommandLineSwitch: () => Effect.void,
48 onBeforeQuitForUpdate: () => Effect.void,
4849 on: () => Effect.void,
4950} satisfies ElectronApp.ElectronApp["Service"]);
5051