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", { 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; readonly register: Effect.Effect< void, never, Scope.Scope | DesktopLifecycleRegistrationServices >; } >()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, eventName: string, listener: (...args: Args) => void, ): Effect.Effect { const eventTarget = target as { on: (eventName: string, listener: (...args: Array) => void) => unknown; removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; }; const untypedListener = listener as unknown as (...args: Array) => 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 = Effect.void, ): Effect.fn.Return { 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: ( effect: Effect.Effect, ) => Promise, 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: ( effect: Effect.Effect, ) => Promise, ): 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(); 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);