import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts";import { makeComponentLogger } from "./DesktopObservability.ts";import * as DesktopShutdown from "./DesktopShutdown.ts";import * as ElectronApp from "../electron/ElectronApp.ts";import * as ElectronTheme from "../electron/ElectronTheme.ts";import * as ElectronWindow from "../electron/ElectronWindow.ts";import * as DesktopState from "./DesktopState.ts";import * as DesktopWindow from "../window/DesktopWindow.ts"; export class DesktopLifecycleRelaunchError extends Schema.TaggedError<DesktopLifecycleRelaunchError>()( "DesktopLifecycleRelaunchError", { reason: Schema.String, cause: Schema.Defect(), },) { override get message(): string { return `Desktop relaunch failed for reason "${this.reason}".`; }} export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopShutdown.DesktopShutdown | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp | ElectronTheme.ElectronTheme; type DesktopLifecycleRegistrationServices = | DesktopLifecycleRuntimeServices | ElectronWindow.ElectronWindow; /** * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow */export class DesktopLifecycle extends Context.Service< DesktopLifecycle, { readonly relaunch: ( reason: string, ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>; readonly register: Effect.Effect< void, never, Scope.Scope | DesktopLifecycleRegistrationServices >; }>()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = makeComponentLogger("desktop-lifecycle"); function addScopedListener<Args extends ReadonlyArray<unknown>>( target: unknown, eventName: string, listener: (...args: Args) => void,): Effect.Effect<void, never, Scope.Scope> { const eventTarget = target as { on: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown; removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown; }; const untypedListener = listener as unknown as (...args: Array<unknown>) => void; return Effect.acquireRelease( Effect.sync(() => { eventTarget.on(eventName, untypedListener); }), () => Effect.sync(() => { eventTarget.removeListener(eventName, untypedListener); }), ).pipe(Effect.asVoid);} const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( function* ( afterBoundsFlush: Effect.Effect<void> = Effect.void, ): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow> { const shutdown = yield* DesktopShutdown.DesktopShutdown; const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.flushMainWindowBounds; yield* afterBoundsFlush; yield* shutdown.request; yield* shutdown.awaitComplete; },); function handleBeforeQuit( event: Electron.Event, runEffect: <A, E>( effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>, ) => Promise<A>, allowQuit: () => boolean, markQuitAllowed: () => void,): void { if (allowQuit()) { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ); return; } event.preventDefault(); void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait( electronWindow.destroyAll.pipe( Effect.catchCause((cause) => logLifecycleError("failed to destroy windows before shutdown", { cause }), ), ), ); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { markQuitAllowed(); void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), ); });} function quitFromSignal( signal: "SIGINT" | "SIGTERM", runEffect: <A, E>( effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>, ) => Promise<A>,): void { void runEffect( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ signal }); const electronApp = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (wasQuitting) return; yield* logLifecycleInfo("process signal received", { signal }); yield* requestDesktopShutdownAndWait(); yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), );} /** @public Service construction is part of the canonical Effect module API. */export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; const state = yield* DesktopState.DesktopState; yield* logLifecycleInfo("desktop relaunch requested", { reason }); yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); yield* requestDesktopShutdownAndWait(); if (environment.isDevelopment) { yield* electronApp.exit(75); return; } yield* electronApp.relaunch({ execPath: process.execPath, args: process.argv.slice(1), }); yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => { const error = new DesktopLifecycleRelaunchError({ reason, cause }); return logLifecycleError(error.message, { error }); }), Effect.forkDetach, Effect.asVoid, ); }), register: Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronWindow = yield* ElectronWindow.ElectronWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context<DesktopLifecycleRegistrationServices>(); 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; // This event is synchronous and the updater's quit proceeds as soon as // the listener returns, so a forked destroyAll would race the quit // and windows could still be open when the process exits (visible on // macOS). Destroy them inline. Effect.runSyncWith(context)( electronWindow.destroyAll.pipe( Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), Effect.catchCause((cause) => logLifecycleError("failed to destroy windows before updater quit", { cause }), ), Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), ), ); }); yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( event, runEffect, () => quitAllowed || updaterQuitAllowed, () => { quitAllowed = true; }, ); }); yield* electronApp.on("activate", () => { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; if (yield* Ref.get(state.quitting)) return; yield* desktopWindow.activate; }).pipe(Effect.withSpan("desktop.lifecycle.activate")), ); }); yield* electronApp.on("window-all-closed", () => { void runEffect( Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { yield* app.quit; } }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), ); }); if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { quitFromSignal("SIGINT", runEffect); }); yield* addScopedListener(process, "SIGTERM", () => { quitFromSignal("SIGTERM", runEffect); }); } }).pipe(Effect.withSpan("desktop.lifecycle.register")),}); export const layer = Layer.succeed(DesktopLifecycle, make);
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import type * as Electron from "electron";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import { makeComponentLogger } from "./DesktopObservability.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
export class DesktopLifecycleRelaunchError extends Schema.TaggedError<DesktopLifecycleRelaunchError>()(
"DesktopLifecycleRelaunchError",
{
reason: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Desktop relaunch failed for reason "${this.reason}".`;
}
}
export type DesktopLifecycleRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopShutdown.DesktopShutdown
| DesktopState.DesktopState
| DesktopWindow.DesktopWindow
| ElectronApp.ElectronApp
| ElectronTheme.ElectronTheme;
type DesktopLifecycleRegistrationServices =
| DesktopLifecycleRuntimeServices
| ElectronWindow.ElectronWindow;
/**
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
*/
export class DesktopLifecycle extends Context.Service<
DesktopLifecycle,
{
readonly relaunch: (
reason: string,
) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
readonly register: Effect.Effect<
void,
never,
Scope.Scope | DesktopLifecycleRegistrationServices
>;
}
>()("@t3tools/desktop/app/DesktopLifecycle") {}
const { logInfo: logLifecycleInfo, logError: logLifecycleError } =
makeComponentLogger("desktop-lifecycle");
function addScopedListener<Args extends ReadonlyArray<unknown>>(
target: unknown,
eventName: string,
listener: (...args: Args) => void,
): Effect.Effect<void, never, Scope.Scope> {
const eventTarget = target as {
on: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
};
const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
return Effect.acquireRelease(
Effect.sync(() => {
eventTarget.on(eventName, untypedListener);
}),
() =>
Effect.sync(() => {
eventTarget.removeListener(eventName, untypedListener);
}),
).pipe(Effect.asVoid);
}
const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
function* (
afterBoundsFlush: Effect.Effect<void> = Effect.void,
): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow> {
const shutdown = yield* DesktopShutdown.DesktopShutdown;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.flushMainWindowBounds;
yield* afterBoundsFlush;
yield* shutdown.request;
yield* shutdown.awaitComplete;
},
);
function handleBeforeQuit(
event: Electron.Event,
runEffect: <A, E>(
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
) => Promise<A>,
allowQuit: () => boolean,
markQuitAllowed: () => void,
): void {
if (allowQuit()) {
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, true);
yield* logLifecycleInfo("before-quit received");
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
);
return;
}
event.preventDefault();
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
const electronWindow = yield* ElectronWindow.ElectronWindow;
yield* Ref.set(state.quitting, true);
yield* logLifecycleInfo("before-quit received");
yield* requestDesktopShutdownAndWait(
electronWindow.destroyAll.pipe(
Effect.catchCause((cause) =>
logLifecycleError("failed to destroy windows before shutdown", { cause }),
),
),
);
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
).finally(() => {
markQuitAllowed();
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
});
}
function quitFromSignal(
signal: "SIGINT" | "SIGTERM",
runEffect: <A, E>(
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
) => Promise<A>,
): void {
void runEffect(
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ signal });
const electronApp = yield* ElectronApp.ElectronApp;
const state = yield* DesktopState.DesktopState;
const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
if (wasQuitting) return;
yield* logLifecycleInfo("process signal received", { signal });
yield* requestDesktopShutdownAndWait();
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
);
}
/** @public Service construction is part of the canonical Effect module API. */
export const make = DesktopLifecycle.of({
relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
const electronApp = yield* ElectronApp.ElectronApp;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const state = yield* DesktopState.DesktopState;
yield* logLifecycleInfo("desktop relaunch requested", { reason });
yield* Effect.gen(function* () {
yield* Effect.yieldNow;
yield* Ref.set(state.quitting, true);
yield* requestDesktopShutdownAndWait();
if (environment.isDevelopment) {
yield* electronApp.exit(75);
return;
}
yield* electronApp.relaunch({
execPath: process.execPath,
args: process.argv.slice(1),
});
yield* electronApp.exit(0);
}).pipe(
Effect.catchCause((cause) => {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
return logLifecycleError(error.message, { error });
}),
Effect.forkDetach,
Effect.asVoid,
);
}),
register: Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const electronApp = yield* ElectronApp.ElectronApp;
const electronTheme = yield* ElectronTheme.ElectronTheme;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
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;
// This event is synchronous and the updater's quit proceeds as soon as
// the listener returns, so a forked destroyAll would race the quit
// and windows could still be open when the process exits (visible on
// macOS). Destroy them inline.
Effect.runSyncWith(context)(
electronWindow.destroyAll.pipe(
Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")),
Effect.catchCause((cause) =>
logLifecycleError("failed to destroy windows before updater quit", { cause }),
),
Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
),
);
});
yield* electronApp.on("before-quit", (event: Electron.Event) => {
handleBeforeQuit(
event,
runEffect,
() => quitAllowed || updaterQuitAllowed,
() => {
quitAllowed = true;
},
);
});
yield* electronApp.on("activate", () => {
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
if (yield* Ref.get(state.quitting)) return;
yield* desktopWindow.activate;
}).pipe(Effect.withSpan("desktop.lifecycle.activate")),
);
});
yield* electronApp.on("window-all-closed", () => {
void runEffect(
Effect.gen(function* () {
const app = yield* ElectronApp.ElectronApp;
const state = yield* DesktopState.DesktopState;
if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
yield* app.quit;
}
}).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
);
});
if (environment.platform !== "win32") {
yield* addScopedListener(process, "SIGINT", () => {
quitFromSignal("SIGINT", runEffect);
});
yield* addScopedListener(process, "SIGTERM", () => {
quitFromSignal("SIGTERM", runEffect);
});
}
}).pipe(Effect.withSpan("desktop.lifecycle.register")),
});
export const layer = Layer.succeed(DesktopLifecycle, make);