Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
calls.setDockIcon.push(iconPath);
}),
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
on: () => Effect.void,
} satisfies ElectronApp.ElectronApp["Service"]);

Expand Down
123 changes: 123 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { assert, describe, it } from "@effect/vitest";
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 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";

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>();

const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
relaunch: () => Effect.void,
setPath: () => Effect.void,
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: (listener) =>
Effect.acquireRelease(
Effect.sync(() => {
appListeners.set("before-quit-for-update", listener);
}),
() =>
Effect.sync(() => {
appListeners.delete("before-quit-for-update");
}),
).pipe(Effect.asVoid),
on: (eventName, listener) =>
Effect.acquireRelease(
Effect.sync(() => {
appListeners.set(
eventName,
listener as unknown as (...args: readonly unknown[]) => void,
);
}),
() =>
Effect.sync(() => {
appListeners.delete(eventName);
}),
).pipe(Effect.asVoid),
} satisfies ElectronApp.ElectronApp["Service"]);

const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
});

const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected window creation"),
ensureMain: Effect.die("unexpected window creation"),
revealOrCreateMain: Effect.die("unexpected window creation"),
activate: Effect.void,
createMainIfBackendReady: Effect.void,
showConnectingSplash: Effect.void,
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.void,
syncAppearance: Effect.void,
});

const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform,
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);

const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(desktopWindowLayer),
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")?.();

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",
);

const state = yield* DesktopState.DesktopState;
assert.isTrue(yield* Ref.get(state.quitting));
}),
).pipe(Effect.provide(layer));
});
}
});
14 changes: 13 additions & 1 deletion apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({
const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
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;
void runEffect(
logLifecycleInfo("allowing updater-controlled quit").pipe(
Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
),
);
});
yield* electronApp.on("before-quit", (event: Electron.Event) => {
handleBeforeQuit(
event,
runEffect,
() => quitAllowed,
() => quitAllowed || updaterQuitAllowed,
() => {
quitAllowed = true;
},
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/electron/ElectronApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test";

const {
appendSwitchMock,
autoUpdaterOnMock,
autoUpdaterRemoveListenerMock,
exitMock,
getAppPathMock,
getVersionMock,
Expand All @@ -23,6 +25,8 @@ const {
whenReadyMock,
} = vi.hoisted(() => ({
appendSwitchMock: vi.fn(),
autoUpdaterOnMock: vi.fn(),
autoUpdaterRemoveListenerMock: vi.fn(),
exitMock: vi.fn(),
getAppPathMock: vi.fn(() => "/app"),
getVersionMock: vi.fn(() => "1.2.3"),
Expand All @@ -43,6 +47,10 @@ const {
}));

vi.mock("electron", () => ({
autoUpdater: {
on: autoUpdaterOnMock,
removeListener: autoUpdaterRemoveListenerMock,
},
app: {
commandLine: {
appendSwitch: appendSwitchMock,
Expand Down Expand Up @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts";
describe("ElectronApp", () => {
beforeEach(() => {
appendSwitchMock.mockClear();
autoUpdaterOnMock.mockClear();
autoUpdaterRemoveListenerMock.mockClear();
exitMock.mockClear();
onMock.mockClear();
quitMock.mockClear();
Expand Down Expand Up @@ -153,4 +163,22 @@ describe("ElectronApp", () => {
assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);
}).pipe(Effect.provide(ElectronApp.layer)),
);

it.effect("scopes native updater quit listeners", () =>
Effect.gen(function* () {
const listener = vi.fn();

yield* Effect.scoped(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.onBeforeQuitForUpdate(listener);
}),
);

assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]);
assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [
["before-quit-for-update", listener],
]);
}).pipe(Effect.provide(ElectronApp.layer)),
);
});
13 changes: 13 additions & 0 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service<
readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
readonly onBeforeQuitForUpdate: (
listener: () => void,
) => Effect.Effect<void, never, Scope.Scope>;
readonly on: <Args extends ReadonlyArray<unknown>>(
eventName: string,
listener: (...args: Args) => void,
Expand Down Expand Up @@ -178,6 +181,16 @@ export const make = ElectronApp.of({
}
Electron.app.commandLine.appendSwitch(switchName, value);
}),
onBeforeQuitForUpdate: (listener) =>
Effect.acquireRelease(
Effect.sync(() => {
Electron.autoUpdater.on("before-quit-for-update", listener);
}),
() =>
Effect.sync(() => {
Electron.autoUpdater.removeListener("before-quit-for-update", listener);
}),
).pipe(Effect.asVoid),
on: addScopedAppListener,
});

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
on: () => Effect.void,
} satisfies ElectronApp.ElectronApp["Service"]);

Expand Down
Loading