import { assert, describe, it } from "@effect/vitest";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts";import * as ElectronTheme from "../electron/ElectronTheme.ts";import * as ElectronWindow from "../electron/ElectronWindow.ts";import * as DesktopEnvironment from "./DesktopEnvironment.ts";import * as DesktopLifecycle from "./DesktopLifecycle.ts";import * as DesktopShutdown from "./DesktopShutdown.ts";import * as DesktopState from "./DesktopState.ts";import * as DesktopWindow from "../window/DesktopWindow.ts"; function makeElectronAppLayer( appListeners: Map<string, (...args: readonly unknown[]) => void>, quit: Effect.Effect<void> = Effect.void,) { const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => Effect.acquireRelease( Effect.sync(() => { appListeners.set(eventName, listener); }), () => Effect.sync(() => { appListeners.delete(eventName); }), ).pipe(Effect.asVoid); return Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit, exit: () => Effect.void, relaunch: () => Effect.void, setPath: () => Effect.void, setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener), on: (eventName, listener) => registerListener(eventName, listener as unknown as (...args: readonly unknown[]) => void), } satisfies ElectronApp.ElectronApp["Service"]);} const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), setSource: () => Effect.void, onUpdated: () => Effect.void,}); function makeElectronWindowLayer(destroyAll: Effect.Effect<void> = Effect.void) { return Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected window creation"), main: Effect.die("unexpected main window read"), currentMainOrFirst: Effect.die("unexpected current window read"), focusedMainOrFirst: Effect.die("unexpected focused window read"), setMain: () => Effect.void, clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, sendAll: () => Effect.void, destroyAll, syncAllAppearance: () => Effect.void, });} function makeDesktopWindowLayer( input: { readonly activate?: Effect.Effect<void>; readonly flushMainWindowBounds?: Effect.Effect<void>; } = {},) { return Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window creation"), ensureMain: Effect.die("unexpected window creation"), revealOrCreateMain: Effect.die("unexpected window creation"), activate: input.activate ?? Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, prepareCaptureReveal: Effect.void, dispatchMenuAction: () => Effect.void, dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.void, syncAppearance: Effect.void, });} describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { const appListeners = new Map<string, (...args: readonly unknown[]) => void>(); let windowsDestroyed = false; const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform, isDevelopment: false, } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners)), Layer.provideMerge(electronThemeLayer), Layer.provideMerge( makeElectronWindowLayer( Effect.sync(() => { windowsDestroyed = true; }), ), ), Layer.provideMerge(makeDesktopWindowLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), ); return Effect.scoped( Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; yield* lifecycle.register; appListeners.get("before-quit-for-update")?.(); yield* Effect.yieldNow; let prevented = false; const event = { preventDefault: () => { prevented = true; }, } as Electron.Event; appListeners.get("before-quit")?.(event); assert.isFalse( prevented, "cancelling this event prevents the updater from completing its relaunch", ); assert.isTrue(windowsDestroyed); const state = yield* DesktopState.DesktopState; assert.isTrue(yield* Ref.get(state.quitting)); }), ).pipe(Effect.provide(layer)); }); } it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { const appListeners = new Map<string, (...args: readonly unknown[]) => void>(); const shutdownRequested = yield* Deferred.make<void>(); const allowShutdown = yield* Deferred.make<void>(); const quitRequested = yield* Deferred.make<void>(); const events: string[] = []; const quit = Effect.sync(() => { events.push("quit"); }).pipe(Effect.andThen(Deferred.succeed(quitRequested, undefined)), Effect.asVoid); const destroyAll = Effect.sync(() => { events.push("destroy"); }); const flushMainWindowBounds = Effect.sync(() => { events.push("flush"); }); const desktopShutdownLayer = Layer.succeed(DesktopShutdown.DesktopShutdown, { request: Effect.sync(() => { events.push("request"); }).pipe(Effect.andThen(Deferred.succeed(shutdownRequested, undefined)), Effect.asVoid), awaitRequest: Deferred.await(shutdownRequested), markComplete: Deferred.succeed(allowShutdown, undefined).pipe(Effect.asVoid), awaitComplete: Deferred.await(allowShutdown), isComplete: Deferred.isDone(allowShutdown), }); const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform: "darwin", isDevelopment: false, } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners, quit)), Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer(destroyAll)), Layer.provideMerge(makeDesktopWindowLayer({ flushMainWindowBounds })), Layer.provideMerge(environmentLayer), Layer.provideMerge(desktopShutdownLayer), Layer.provideMerge(DesktopState.layer), ); yield* Effect.scoped( Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; yield* lifecycle.register; const event = { preventDefault: () => undefined } as Electron.Event; appListeners.get("before-quit")?.(event); yield* Deferred.await(shutdownRequested); const eventsBeforeCleanup = [...events]; yield* Deferred.succeed(allowShutdown, undefined); yield* Deferred.await(quitRequested); assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]); assert.deepEqual(events, ["flush", "destroy", "request", "quit"]); }), ).pipe(Effect.provide(layer)); }), ); it.effect("ignores app activation while quitting", () => Effect.gen(function* () { const appListeners = new Map<string, (...args: readonly unknown[]) => void>(); let activationCount = 0; const activate = Effect.sync(() => { activationCount += 1; }); const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform: "darwin", isDevelopment: false, } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners)), Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer()), Layer.provideMerge(makeDesktopWindowLayer({ activate })), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), ); yield* Effect.scoped( Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const state = yield* DesktopState.DesktopState; yield* lifecycle.register; yield* Ref.set(state.quitting, true); appListeners.get("activate")?.(); assert.equal(activationCount, 0); }), ).pipe(Effect.provide(layer)); }), );});
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import type * as Electron from "electron";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
function makeElectronAppLayer(
appListeners: Map<string, (...args: readonly unknown[]) => void>,
quit: Effect.Effect<void> = Effect.void,
) {
const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
Effect.acquireRelease(
Effect.sync(() => {
appListeners.set(eventName, listener);
}),
() =>
Effect.sync(() => {
appListeners.delete(eventName);
}),
).pipe(Effect.asVoid);
return Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit,
exit: () => Effect.void,
relaunch: () => Effect.void,
setPath: () => Effect.void,
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
getAppMetrics: Effect.succeed([]),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
removeCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener),
on: (eventName, listener) =>
registerListener(eventName, listener as unknown as (...args: readonly unknown[]) => void),
} satisfies ElectronApp.ElectronApp["Service"]);
}
const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
});
function makeElectronWindowLayer(destroyAll: Effect.Effect<void> = Effect.void) {
return Layer.succeed(ElectronWindow.ElectronWindow, {
create: () => Effect.die("unexpected window creation"),
main: Effect.die("unexpected main window read"),
currentMainOrFirst: Effect.die("unexpected current window read"),
focusedMainOrFirst: Effect.die("unexpected focused window read"),
setMain: () => Effect.void,
clearMain: () => Effect.void,
prepareReveal: () => Effect.succeed(false),
reveal: () => Effect.void,
sendAll: () => Effect.void,
destroyAll,
syncAllAppearance: () => Effect.void,
});
}
function makeDesktopWindowLayer(
input: {
readonly activate?: Effect.Effect<void>;
readonly flushMainWindowBounds?: Effect.Effect<void>;
} = {},
) {
return Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected window creation"),
ensureMain: Effect.die("unexpected window creation"),
revealOrCreateMain: Effect.die("unexpected window creation"),
activate: input.activate ?? Effect.void,
createMainIfBackendReady: Effect.void,
showConnectingSplash: Effect.void,
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void,
prepareCaptureReveal: Effect.void,
dispatchMenuAction: () => Effect.void,
dispatchSnapShotEvent: () => Effect.void,
zoomMain: () => Effect.void,
syncAppearance: Effect.void,
});
}
describe("DesktopLifecycle", () => {
for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
let windowsDestroyed = false;
const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform,
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(makeElectronAppLayer(appListeners)),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(
makeElectronWindowLayer(
Effect.sync(() => {
windowsDestroyed = true;
}),
),
),
Layer.provideMerge(makeDesktopWindowLayer()),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
);
return Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;
appListeners.get("before-quit-for-update")?.();
yield* Effect.yieldNow;
let prevented = false;
const event = {
preventDefault: () => {
prevented = true;
},
} as Electron.Event;
appListeners.get("before-quit")?.(event);
assert.isFalse(
prevented,
"cancelling this event prevents the updater from completing its relaunch",
);
assert.isTrue(windowsDestroyed);
const state = yield* DesktopState.DesktopState;
assert.isTrue(yield* Ref.get(state.quitting));
}),
).pipe(Effect.provide(layer));
});
}
it.effect("destroys windows before waiting for backend shutdown", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const shutdownRequested = yield* Deferred.make<void>();
const allowShutdown = yield* Deferred.make<void>();
const quitRequested = yield* Deferred.make<void>();
const events: string[] = [];
const quit = Effect.sync(() => {
events.push("quit");
}).pipe(Effect.andThen(Deferred.succeed(quitRequested, undefined)), Effect.asVoid);
const destroyAll = Effect.sync(() => {
events.push("destroy");
});
const flushMainWindowBounds = Effect.sync(() => {
events.push("flush");
});
const desktopShutdownLayer = Layer.succeed(DesktopShutdown.DesktopShutdown, {
request: Effect.sync(() => {
events.push("request");
}).pipe(Effect.andThen(Deferred.succeed(shutdownRequested, undefined)), Effect.asVoid),
awaitRequest: Deferred.await(shutdownRequested),
markComplete: Deferred.succeed(allowShutdown, undefined).pipe(Effect.asVoid),
awaitComplete: Deferred.await(allowShutdown),
isComplete: Deferred.isDone(allowShutdown),
});
const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(makeElectronAppLayer(appListeners, quit)),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(makeElectronWindowLayer(destroyAll)),
Layer.provideMerge(makeDesktopWindowLayer({ flushMainWindowBounds })),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(desktopShutdownLayer),
Layer.provideMerge(DesktopState.layer),
);
yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;
const event = { preventDefault: () => undefined } as Electron.Event;
appListeners.get("before-quit")?.(event);
yield* Deferred.await(shutdownRequested);
const eventsBeforeCleanup = [...events];
yield* Deferred.succeed(allowShutdown, undefined);
yield* Deferred.await(quitRequested);
assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]);
assert.deepEqual(events, ["flush", "destroy", "request", "quit"]);
}),
).pipe(Effect.provide(layer));
}),
);
it.effect("ignores app activation while quitting", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
let activationCount = 0;
const activate = Effect.sync(() => {
activationCount += 1;
});
const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(makeElectronAppLayer(appListeners)),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(makeElectronWindowLayer()),
Layer.provideMerge(makeDesktopWindowLayer({ activate })),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
);
yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const state = yield* DesktopState.DesktopState;
yield* lifecycle.register;
yield* Ref.set(state.quitting, true);
appListeners.get("activate")?.();
assert.equal(activationCount, 0);
}),
).pipe(Effect.provide(layer));
}),
);
});