267 lines · 9.5 KB · TypeScript
RawDownload
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);