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) =>...@@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
63 calls.setDockIcon.push(iconPath);63 calls.setDockIcon.push(iconPath);
64 }),64 }),
65 appendCommandLineSwitch: () => Effect.void,65 appendCommandLineSwitch: () => Effect.void,
66 onBeforeQuitForUpdate: () => Effect.void,
66 on: () => Effect.void,67 on: () => Effect.void,
67 } satisfies ElectronApp.ElectronApp["Service"]);68 } 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({...@@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({
176 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();176 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
177 const runEffect = Effect.runPromiseWith(context);177 const runEffect = Effect.runPromiseWith(context);
178 let quitAllowed = false;178 let quitAllowed = false;
179 let updaterQuitAllowed = false;
179 yield* electronTheme.onUpdated(() => {180 yield* electronTheme.onUpdated(() => {
180 void runEffect(181 void runEffect(
181 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),182 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
182 );183 );
183 });184 });
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 });
184 yield* electronApp.on("before-quit", (event: Electron.Event) => {196 yield* electronApp.on("before-quit", (event: Electron.Event) => {
185 handleBeforeQuit(197 handleBeforeQuit(
186 event,198 event,
187 runEffect,199 runEffect,
188 () => quitAllowed,200 () => quitAllowed || updaterQuitAllowed,
189 () => {201 () => {
190 quitAllowed = true;202 quitAllowed = true;
191 },203 },
apps/desktop/src/electron/ElectronApp.test.ts+28
...@@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test";...@@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test";
44
5const {5const {
6 appendSwitchMock,6 appendSwitchMock,
7 autoUpdaterOnMock,
8 autoUpdaterRemoveListenerMock,
7 exitMock,9 exitMock,
8 getAppPathMock,10 getAppPathMock,
9 getVersionMock,11 getVersionMock,
...@@ -23,6 +25,8 @@ const {...@@ -23,6 +25,8 @@ const {
23 whenReadyMock,25 whenReadyMock,
24} = vi.hoisted(() => ({26} = vi.hoisted(() => ({
25 appendSwitchMock: vi.fn(),27 appendSwitchMock: vi.fn(),
28 autoUpdaterOnMock: vi.fn(),
29 autoUpdaterRemoveListenerMock: vi.fn(),
26 exitMock: vi.fn(),30 exitMock: vi.fn(),
27 getAppPathMock: vi.fn(() => "/app"),31 getAppPathMock: vi.fn(() => "/app"),
28 getVersionMock: vi.fn(() => "1.2.3"),32 getVersionMock: vi.fn(() => "1.2.3"),
...@@ -43,6 +47,10 @@ const {...@@ -43,6 +47,10 @@ const {
43}));47}));
4448
45vi.mock("electron", () => ({49vi.mock("electron", () => ({
50 autoUpdater: {
51 on: autoUpdaterOnMock,
52 removeListener: autoUpdaterRemoveListenerMock,
53 },
46 app: {54 app: {
47 commandLine: {55 commandLine: {
48 appendSwitch: appendSwitchMock,56 appendSwitch: appendSwitchMock,
...@@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts";...@@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts";
77describe("ElectronApp", () => {85describe("ElectronApp", () => {
78 beforeEach(() => {86 beforeEach(() => {
79 appendSwitchMock.mockClear();87 appendSwitchMock.mockClear();
88 autoUpdaterOnMock.mockClear();
89 autoUpdaterRemoveListenerMock.mockClear();
80 exitMock.mockClear();90 exitMock.mockClear();
81 onMock.mockClear();91 onMock.mockClear();
82 quitMock.mockClear();92 quitMock.mockClear();
...@@ -153,4 +163,22 @@ describe("ElectronApp", () => {...@@ -153,4 +163,22 @@ describe("ElectronApp", () => {
153 assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);163 assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);
154 }).pipe(Effect.provide(ElectronApp.layer)),164 }).pipe(Effect.provide(ElectronApp.layer)),
155 );165 );
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 );
156});184});
apps/desktop/src/electron/ElectronApp.ts+13
...@@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service<...@@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service<
66 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;66 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
67 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;67 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
68 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;68 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
69 readonly onBeforeQuitForUpdate: (
70 listener: () => void,
71 ) => Effect.Effect<void, never, Scope.Scope>;
69 readonly on: <Args extends ReadonlyArray<unknown>>(72 readonly on: <Args extends ReadonlyArray<unknown>>(
70 eventName: string,73 eventName: string,
71 listener: (...args: Args) => void,74 listener: (...args: Args) => void,
...@@ -178,6 +181,16 @@ export const make = ElectronApp.of({...@@ -178,6 +181,16 @@ export const make = ElectronApp.of({
178 }181 }
179 Electron.app.commandLine.appendSwitch(switchName, value);182 Electron.app.commandLine.appendSwitch(switchName, value);
180 }),183 }),
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),
181 on: addScopedAppListener,194 on: addScopedAppListener,
182});195});
183196
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
...@@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {...@@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
45 setDesktopName: () => Effect.void,45 setDesktopName: () => Effect.void,
46 setDockIcon: () => Effect.void,46 setDockIcon: () => Effect.void,
47 appendCommandLineSwitch: () => Effect.void,47 appendCommandLineSwitch: () => Effect.void,
48 onBeforeQuitForUpdate: () => Effect.void,
48 on: () => Effect.void,49 on: () => Effect.void,
49} satisfies ElectronApp.ElectronApp["Service"]);50} satisfies ElectronApp.ElectronApp["Service"]);
5051