1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";
5import * as Schema from "effect/Schema";
6import * as Scope from "effect/Scope";
7
8import type * as Electron from "electron";
9
10import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11import { makeComponentLogger } from "./DesktopObservability.ts";
12import * as DesktopShutdown from "./DesktopShutdown.ts";
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as ElectronTheme from "../electron/ElectronTheme.ts";
15import * as ElectronWindow from "../electron/ElectronWindow.ts";
16import * as DesktopState from "./DesktopState.ts";
17import * as DesktopWindow from "../window/DesktopWindow.ts";
18
19export class DesktopLifecycleRelaunchError extends Schema.TaggedError<DesktopLifecycleRelaunchError>()(
20 "DesktopLifecycleRelaunchError",
21 {
22 reason: Schema.String,
23 cause: Schema.Defect(),
24 },
25) {
26 override get message(): string {
27 return `Desktop relaunch failed for reason "${this.reason}".`;
28 }
29}
30
31export type DesktopLifecycleRuntimeServices =
32 | DesktopEnvironment.DesktopEnvironment
33 | DesktopShutdown.DesktopShutdown
34 | DesktopState.DesktopState
35 | DesktopWindow.DesktopWindow
36 | ElectronApp.ElectronApp
37 | ElectronTheme.ElectronTheme;
38
39type DesktopLifecycleRegistrationServices =
40 | DesktopLifecycleRuntimeServices
41 | ElectronWindow.ElectronWindow;
42
43/**
44 * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
45 */
46export class DesktopLifecycle extends Context.Service<
47 DesktopLifecycle,
48 {
49 readonly relaunch: (
50 reason: string,
51 ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
52 readonly register: Effect.Effect<
53 void,
54 never,
55 Scope.Scope | DesktopLifecycleRegistrationServices
56 >;
57 }
58>()("@t3tools/desktop/app/DesktopLifecycle") {}
59
60const { logInfo: logLifecycleInfo, logError: logLifecycleError } =
61 makeComponentLogger("desktop-lifecycle");
62
63function addScopedListener<Args extends ReadonlyArray<unknown>>(
64 target: unknown,
65 eventName: string,
66 listener: (...args: Args) => void,
67): Effect.Effect<void, never, Scope.Scope> {
68 const eventTarget = target as {
69 on: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
70 removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
71 };
72 const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
73 return Effect.acquireRelease(
74 Effect.sync(() => {
75 eventTarget.on(eventName, untypedListener);
76 }),
77 () =>
78 Effect.sync(() => {
79 eventTarget.removeListener(eventName, untypedListener);
80 }),
81 ).pipe(Effect.asVoid);
82}
83
84const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
85 function* (
86 afterBoundsFlush: Effect.Effect<void> = Effect.void,
87 ): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow> {
88 const shutdown = yield* DesktopShutdown.DesktopShutdown;
89 const desktopWindow = yield* DesktopWindow.DesktopWindow;
90 yield* desktopWindow.flushMainWindowBounds;
91 yield* afterBoundsFlush;
92 yield* shutdown.request;
93 yield* shutdown.awaitComplete;
94 },
95);
96
97function handleBeforeQuit(
98 event: Electron.Event,
99 runEffect: <A, E>(
100 effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
101 ) => Promise<A>,
102 allowQuit: () => boolean,
103 markQuitAllowed: () => void,
104): void {
105 if (allowQuit()) {
106 void runEffect(
107 Effect.gen(function* () {
108 const state = yield* DesktopState.DesktopState;
109 yield* Ref.set(state.quitting, true);
110 yield* logLifecycleInfo("before-quit received");
111 }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
112 );
113 return;
114 }
115
116 event.preventDefault();
117 void runEffect(
118 Effect.gen(function* () {
119 const state = yield* DesktopState.DesktopState;
120 const electronWindow = yield* ElectronWindow.ElectronWindow;
121 yield* Ref.set(state.quitting, true);
122 yield* logLifecycleInfo("before-quit received");
123 yield* requestDesktopShutdownAndWait(
124 electronWindow.destroyAll.pipe(
125 Effect.catchCause((cause) =>
126 logLifecycleError("failed to destroy windows before shutdown", { cause }),
127 ),
128 ),
129 );
130 }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
131 ).finally(() => {
132 markQuitAllowed();
133 void runEffect(
134 Effect.gen(function* () {
135 const electronApp = yield* ElectronApp.ElectronApp;
136 yield* electronApp.quit;
137 }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
138 );
139 });
140}
141
142function quitFromSignal(
143 signal: "SIGINT" | "SIGTERM",
144 runEffect: <A, E>(
145 effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
146 ) => Promise<A>,
147): void {
148 void runEffect(
149 Effect.gen(function* () {
150 yield* Effect.annotateCurrentSpan({ signal });
151 const electronApp = yield* ElectronApp.ElectronApp;
152 const state = yield* DesktopState.DesktopState;
153 const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
154 if (wasQuitting) return;
155 yield* logLifecycleInfo("process signal received", { signal });
156 yield* requestDesktopShutdownAndWait();
157 yield* electronApp.quit;
158 }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
159 );
160}
161
162/** @public Service construction is part of the canonical Effect module API. */
163export const make = DesktopLifecycle.of({
164 relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
165 const electronApp = yield* ElectronApp.ElectronApp;
166 const environment = yield* DesktopEnvironment.DesktopEnvironment;
167 const state = yield* DesktopState.DesktopState;
168 yield* logLifecycleInfo("desktop relaunch requested", { reason });
169 yield* Effect.gen(function* () {
170 yield* Effect.yieldNow;
171 yield* Ref.set(state.quitting, true);
172 yield* requestDesktopShutdownAndWait();
173 if (environment.isDevelopment) {
174 yield* electronApp.exit(75);
175 return;
176 }
177 yield* electronApp.relaunch({
178 execPath: process.execPath,
179 args: process.argv.slice(1),
180 });
181 yield* electronApp.exit(0);
182 }).pipe(
183 Effect.catchCause((cause) => {
184 const error = new DesktopLifecycleRelaunchError({ reason, cause });
185 return logLifecycleError(error.message, { error });
186 }),
187 Effect.forkDetach,
188 Effect.asVoid,
189 );
190 }),
191 register: Effect.gen(function* () {
192 const desktopWindow = yield* DesktopWindow.DesktopWindow;
193 const electronWindow = yield* ElectronWindow.ElectronWindow;
194 const electronApp = yield* ElectronApp.ElectronApp;
195 const electronTheme = yield* ElectronTheme.ElectronTheme;
196 const environment = yield* DesktopEnvironment.DesktopEnvironment;
197 const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
198 const runEffect = Effect.runPromiseWith(context);
199 let quitAllowed = false;
200 let updaterQuitAllowed = false;
201 yield* electronTheme.onUpdated(() => {
202 void runEffect(
203 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
204 );
205 });
206 yield* electronApp.onBeforeQuitForUpdate(() => {
207 // Electron's updater owns the remaining quit/install/relaunch sequence.
208 // Cancelling the following app "before-quit" event breaks that sequence,
209 // most visibly on macOS where the native updater performs the relaunch.
210 updaterQuitAllowed = true;
211 // This event is synchronous and the updater's quit proceeds as soon as
212 // the listener returns, so a forked destroyAll would race the quit
213 // and windows could still be open when the process exits (visible on
214 // macOS). Destroy them inline.
215 Effect.runSyncWith(context)(
216 electronWindow.destroyAll.pipe(
217 Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")),
218 Effect.catchCause((cause) =>
219 logLifecycleError("failed to destroy windows before updater quit", { cause }),
220 ),
221 Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
222 ),
223 );
224 });
225 yield* electronApp.on("before-quit", (event: Electron.Event) => {
226 handleBeforeQuit(
227 event,
228 runEffect,
229 () => quitAllowed || updaterQuitAllowed,
230 () => {
231 quitAllowed = true;
232 },
233 );
234 });
235 yield* electronApp.on("activate", () => {
236 void runEffect(
237 Effect.gen(function* () {
238 const state = yield* DesktopState.DesktopState;
239 if (yield* Ref.get(state.quitting)) return;
240 yield* desktopWindow.activate;
241 }).pipe(Effect.withSpan("desktop.lifecycle.activate")),
242 );
243 });
244 yield* electronApp.on("window-all-closed", () => {
245 void runEffect(
246 Effect.gen(function* () {
247 const app = yield* ElectronApp.ElectronApp;
248 const state = yield* DesktopState.DesktopState;
249 if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
250 yield* app.quit;
251 }
252 }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
253 );
254 });
255
256 if (environment.platform !== "win32") {
257 yield* addScopedListener(process, "SIGINT", () => {
258 quitFromSignal("SIGINT", runEffect);
259 });
260 yield* addScopedListener(process, "SIGTERM", () => {
261 quitFromSignal("SIGTERM", runEffect);
262 });
263 }
264 }).pipe(Effect.withSpan("desktop.lifecycle.register")),
265});
266
267export const layer = Layer.succeed(DesktopLifecycle, make);