1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import type * as Electron from "electron";
8
9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as ElectronTheme from "../electron/ElectronTheme.ts";
11import * as ElectronWindow from "../electron/ElectronWindow.ts";
12import * as DesktopEnvironment from "./DesktopEnvironment.ts";
13import * as DesktopLifecycle from "./DesktopLifecycle.ts";
14import * as DesktopShutdown from "./DesktopShutdown.ts";
15import * as DesktopState from "./DesktopState.ts";
16import * as DesktopWindow from "../window/DesktopWindow.ts";
17
18function layerElectronApp(
19 appListeners: Map<string, (...args: readonly unknown[]) => void>,
20 quit: Effect.Effect<void> = Effect.void,
21) {
22 const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
23 Effect.acquireRelease(
24 Effect.sync(() => {
25 appListeners.set(eventName, listener);
26 }),
27 () =>
28 Effect.sync(() => {
29 appListeners.delete(eventName);
30 }),
31 ).pipe(Effect.asVoid);
32
33 return Layer.succeed(ElectronApp.ElectronApp, {
34 metadata: Effect.die("unexpected metadata read"),
35 name: Effect.succeed("T3 Code"),
36 systemLocale: Effect.succeed("en-US"),
37 whenReady: Effect.void,
38 quit,
39 exit: () => Effect.void,
40 relaunch: () => Effect.void,
41 setPath: () => Effect.void,
42 setName: () => Effect.void,
43 setAboutPanelOptions: () => Effect.void,
44 setAppUserModelId: () => Effect.void,
45 getAppMetrics: Effect.succeed([]),
46 setAsDefaultProtocolClient: () => Effect.succeed(true),
47 setDesktopName: () => Effect.void,
48 setDockIcon: () => Effect.void,
49 appendCommandLineSwitch: () => Effect.void,
50 removeCommandLineSwitch: () => Effect.void,
51 onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener),
52 on: (eventName, listener) =>
53 registerListener(eventName, listener as unknown as (...args: readonly unknown[]) => void),
54 } satisfies ElectronApp.ElectronApp["Service"]);
55}
56
57const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, {
58 shouldUseDarkColors: Effect.succeed(false),
59 setSource: () => Effect.void,
60 onUpdated: () => Effect.void,
61});
62
63function layerElectronWindow(destroyAll: Effect.Effect<void> = Effect.void) {
64 return Layer.succeed(ElectronWindow.ElectronWindow, {
65 create: () => Effect.die("unexpected window creation"),
66 main: Effect.die("unexpected main window read"),
67 currentMainOrFirst: Effect.die("unexpected current window read"),
68 focusedMainOrFirst: Effect.die("unexpected focused window read"),
69 setMain: () => Effect.void,
70 clearMain: () => Effect.void,
71 prepareReveal: () => Effect.succeed(false),
72 reveal: () => Effect.void,
73 sendAll: () => Effect.void,
74 destroyAll,
75 syncAllAppearance: () => Effect.void,
76 });
77}
78
79function layerDesktopWindow(
80 input: {
81 readonly activate?: Effect.Effect<void>;
82 readonly flushMainWindowBounds?: Effect.Effect<void>;
83 } = {},
84) {
85 return Layer.succeed(DesktopWindow.DesktopWindow, {
86 createMain: Effect.die("unexpected window creation"),
87 ensureMain: Effect.die("unexpected window creation"),
88 revealOrCreateMain: Effect.die("unexpected window creation"),
89 activate: input.activate ?? Effect.void,
90 createMainIfBackendReady: Effect.void,
91 showConnectingSplash: Effect.void,
92 handleBackendReady: () => Effect.void,
93 handleBackendNotReady: Effect.void,
94 flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void,
95 prepareCaptureReveal: Effect.void,
96 dispatchMenuAction: () => Effect.void,
97 dispatchSnapShotEvent: () => Effect.void,
98 zoomMain: () => Effect.void,
99 runMainContentsCommand: () => Effect.void,
100 syncAppearance: Effect.void,
101 });
102}
103
104describe("DesktopLifecycle", () => {
105 it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)(
106 "lets the updater's quit event proceed on %s",
107 (platform) => {
108 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
109 let windowsDestroyed = false;
110 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
111 platform,
112 isDevelopment: false,
113 } as DesktopEnvironment.DesktopEnvironment["Service"]);
114
115 const layer = DesktopLifecycle.layer.pipe(
116 Layer.provideMerge(layerElectronApp(appListeners)),
117 Layer.provideMerge(layerElectronTheme),
118 Layer.provideMerge(
119 layerElectronWindow(
120 Effect.sync(() => {
121 windowsDestroyed = true;
122 }),
123 ),
124 ),
125 Layer.provideMerge(layerDesktopWindow()),
126 Layer.provideMerge(layerEnvironment),
127 Layer.provideMerge(DesktopShutdown.layer),
128 Layer.provideMerge(DesktopState.layer),
129 );
130
131 return Effect.scoped(
132 Effect.gen(function* () {
133 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
134 yield* lifecycle.register;
135
136 appListeners.get("before-quit-for-update")?.();
137 yield* Effect.yieldNow;
138
139 let prevented = false;
140 const event = {
141 preventDefault: () => {
142 prevented = true;
143 },
144 } as Electron.Event;
145 appListeners.get("before-quit")?.(event);
146
147 assert.isFalse(
148 prevented,
149 "cancelling this event prevents the updater from completing its relaunch",
150 );
151 assert.isTrue(windowsDestroyed);
152
153 const state = yield* DesktopState.DesktopState;
154 assert.isTrue(yield* Ref.get(state.quitting));
155 }),
156 ).pipe(Effect.provide(layer));
157 },
158 );
159
160 it.effect("destroys windows before waiting for backend shutdown", () =>
161 Effect.gen(function* () {
162 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
163 const shutdownRequested = yield* Deferred.make<void>();
164 const allowShutdown = yield* Deferred.make<void>();
165 const quitRequested = yield* Deferred.make<void>();
166 const events: string[] = [];
167
168 const quit = Effect.sync(() => {
169 events.push("quit");
170 }).pipe(Effect.andThen(Deferred.succeed(quitRequested, undefined)), Effect.asVoid);
171 const destroyAll = Effect.sync(() => {
172 events.push("destroy");
173 });
174 const flushMainWindowBounds = Effect.sync(() => {
175 events.push("flush");
176 });
177
178 const layerDesktopShutdown = Layer.succeed(DesktopShutdown.DesktopShutdown, {
179 request: Effect.sync(() => {
180 events.push("request");
181 }).pipe(Effect.andThen(Deferred.succeed(shutdownRequested, undefined)), Effect.asVoid),
182 awaitRequest: Deferred.await(shutdownRequested),
183 markComplete: Deferred.succeed(allowShutdown, undefined).pipe(Effect.asVoid),
184 awaitComplete: Deferred.await(allowShutdown),
185 isComplete: Deferred.isDone(allowShutdown),
186 });
187
188 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
189 platform: "darwin",
190 isDevelopment: false,
191 } as DesktopEnvironment.DesktopEnvironment["Service"]);
192
193 const layer = DesktopLifecycle.layer.pipe(
194 Layer.provideMerge(layerElectronApp(appListeners, quit)),
195 Layer.provideMerge(layerElectronTheme),
196 Layer.provideMerge(layerElectronWindow(destroyAll)),
197 Layer.provideMerge(layerDesktopWindow({ flushMainWindowBounds })),
198 Layer.provideMerge(layerEnvironment),
199 Layer.provideMerge(layerDesktopShutdown),
200 Layer.provideMerge(DesktopState.layer),
201 );
202
203 yield* Effect.scoped(
204 Effect.gen(function* () {
205 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
206 yield* lifecycle.register;
207
208 const event = { preventDefault: () => undefined } as Electron.Event;
209 appListeners.get("before-quit")?.(event);
210
211 yield* Deferred.await(shutdownRequested);
212 const eventsBeforeCleanup = [...events];
213 yield* Deferred.succeed(allowShutdown, undefined);
214 yield* Deferred.await(quitRequested);
215
216 assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]);
217 assert.deepEqual(events, ["flush", "destroy", "request", "quit"]);
218 }),
219 ).pipe(Effect.provide(layer));
220 }),
221 );
222
223 it.effect("ignores app activation while quitting", () =>
224 Effect.gen(function* () {
225 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
226 let activationCount = 0;
227 const activate = Effect.sync(() => {
228 activationCount += 1;
229 });
230 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
231 platform: "darwin",
232 isDevelopment: false,
233 } as DesktopEnvironment.DesktopEnvironment["Service"]);
234 const layer = DesktopLifecycle.layer.pipe(
235 Layer.provideMerge(layerElectronApp(appListeners)),
236 Layer.provideMerge(layerElectronTheme),
237 Layer.provideMerge(layerElectronWindow()),
238 Layer.provideMerge(layerDesktopWindow({ activate })),
239 Layer.provideMerge(layerEnvironment),
240 Layer.provideMerge(DesktopShutdown.layer),
241 Layer.provideMerge(DesktopState.layer),
242 );
243
244 yield* Effect.scoped(
245 Effect.gen(function* () {
246 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
247 const state = yield* DesktopState.DesktopState;
248 yield* lifecycle.register;
249 yield* Ref.set(state.quitting, true);
250
251 appListeners.get("activate")?.();
252
253 assert.equal(activationCount, 0);
254 }),
255 ).pipe(Effect.provide(layer));
256 }),
257 );
258});