Skip to content
Merged
145 changes: 145 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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 ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
Expand Down Expand Up @@ -80,6 +82,10 @@ describe("DesktopLifecycle", () => {
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.void,
zoomMain: () => Effect.void,
runningActionCount: Effect.succeed(0),
reportRunningActionCount: () => Effect.void,
acknowledgeRunningActionQuitWarning: Effect.void,
consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false),
syncAppearance: Effect.void,
});

Expand All @@ -90,6 +96,7 @@ describe("DesktopLifecycle", () => {

const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(ElectronDialog.layer),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(desktopWindowLayer),
Layer.provideMerge(environmentLayer),
Expand Down Expand Up @@ -123,4 +130,142 @@ describe("DesktopLifecycle", () => {
).pipe(Effect.provide(layer));
});
}

it.effect("warns with the running Action count before quitting", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const shownOptions = yield* Deferred.make<Electron.MessageBoxOptions>();
const dialogResponse = yield* Deferred.make<Electron.MessageBoxReturnValue>();
const quitCalled = yield* Deferred.make<void>();

const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Deferred.succeed(quitCalled, undefined).pipe(Effect.asVoid),
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) =>
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 electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.die("unexpected folder picker"),
pickFiles: () => Effect.die("unexpected file picker"),
showMessageBox: (options) =>
Deferred.succeed(shownOptions, options).pipe(
Effect.andThen(Deferred.await(dialogResponse)),
),
showErrorBox: () => Effect.die("unexpected error dialog"),
});

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,
zoomMain: () => Effect.void,
runningActionCount: Effect.succeed(3),
reportRunningActionCount: () => Effect.void,
acknowledgeRunningActionQuitWarning: Effect.void,
consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false),
syncAppearance: Effect.void,
});

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

const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(electronDialogLayer),
Layer.provideMerge(
Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
}),
),
Layer.provideMerge(desktopWindowLayer),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(
Layer.succeed(DesktopShutdown.DesktopShutdown, {
request: Effect.void,
awaitRequest: Effect.void,
markComplete: Effect.void,
awaitComplete: Effect.void,
isComplete: Effect.succeed(true),
}),
),
Layer.provideMerge(DesktopState.layer),
);

yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;

let prevented = false;
appListeners.get("before-quit")?.({
preventDefault: () => {
prevented = true;
},
} as Electron.Event);

const options = yield* Deferred.await(shownOptions);
assert.isTrue(prevented);
assert.equal(options.title, "Running Actions");
assert.equal(options.message, "Quit and cancel 3 running Actions?");
assert.equal(options.detail, "The commands will not be restarted automatically.");
assert.deepEqual(options.buttons, ["Quit and cancel", "Keep running"]);

yield* Deferred.succeed(dialogResponse, {
response: 0,
checkboxChecked: false,
});
yield* Deferred.await(quitCalled);
}),
).pipe(Effect.provide(layer));
}),
);
});
64 changes: 52 additions & 12 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
Expand All @@ -33,10 +34,11 @@ export type DesktopLifecycleRuntimeServices =
| DesktopState.DesktopState
| DesktopWindow.DesktopWindow
| ElectronApp.ElectronApp
| ElectronDialog.ElectronDialog
| ElectronTheme.ElectronTheme;

/**
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronDialog | ElectronTheme
*/
export class DesktopLifecycle extends Context.Service<
DesktopLifecycle,
Expand Down Expand Up @@ -91,6 +93,8 @@ function handleBeforeQuit(
runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
allowQuit: () => boolean,
markQuitAllowed: () => void,
isQuitRequestPending: () => boolean,
setQuitRequestPending: (pending: boolean) => void,
): void {
if (allowQuit()) {
void runEffect(
Expand All @@ -104,27 +108,53 @@ function handleBeforeQuit(
}

event.preventDefault();
if (isQuitRequestPending()) return;
setQuitRequestPending(true);
void runEffect(
Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const warningAcknowledged =
yield* desktopWindow.consumeRunningActionQuitWarningAcknowledgment;
const runningActionCount = yield* desktopWindow.runningActionCount;
if (runningActionCount > 0 && !warningAcknowledged) {
const dialog = yield* ElectronDialog.ElectronDialog;
const result = yield* dialog.showMessageBox({
type: "warning",
title: "Running Actions",
message: `Quit and cancel ${runningActionCount} running ${runningActionCount === 1 ? "Action" : "Actions"}?`,
detail: "The commands will not be restarted automatically.",
buttons: ["Quit and cancel", "Keep running"],
defaultId: 1,
cancelId: 1,
noLink: true,
});
if (result.response !== 0) return false;
}
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, true);
yield* logLifecycleInfo("before-quit received");
yield* requestDesktopShutdownAndWait();
return true;
}).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")),
);
});
)
.then((shouldQuit) => {
if (!shouldQuit) return;
markQuitAllowed();
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
})
.catch(() => undefined)
.finally(() => setQuitRequestPending(false));
}

function quitFromSignal(
signal: "SIGINT" | "SIGTERM",
runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
markQuitAllowed: () => void,
): void {
void runEffect(
Effect.gen(function* () {
Expand All @@ -135,6 +165,7 @@ function quitFromSignal(
if (wasQuitting) return;
yield* logLifecycleInfo("process signal received", { signal });
yield* requestDesktopShutdownAndWait();
markQuitAllowed();
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
);
Expand Down Expand Up @@ -176,6 +207,7 @@ export const make = DesktopLifecycle.of({
const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
const runEffect = Effect.runPromiseWith(context);
let quitAllowed = false;
let quitRequestPending = false;
let updaterQuitAllowed = false;
yield* electronTheme.onUpdated(() => {
void runEffect(
Expand All @@ -201,6 +233,10 @@ export const make = DesktopLifecycle.of({
() => {
quitAllowed = true;
},
() => quitRequestPending,
(pending) => {
quitRequestPending = pending;
},
);
});
yield* electronApp.on("activate", () => {
Expand All @@ -220,10 +256,14 @@ export const make = DesktopLifecycle.of({

if (environment.platform !== "win32") {
yield* addScopedListener(process, "SIGINT", () => {
quitFromSignal("SIGINT", runEffect);
quitFromSignal("SIGINT", runEffect, () => {
quitAllowed = true;
});
});
yield* addScopedListener(process, "SIGTERM", () => {
quitFromSignal("SIGTERM", runEffect);
quitFromSignal("SIGTERM", runEffect, () => {
quitAllowed = true;
});
});
}
}).pipe(Effect.withSpan("desktop.lifecycle.register")),
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ function makePoolLayer(
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.die("unexpected menu action"),
zoomMain: () => Effect.die("unexpected zoom"),
runningActionCount: Effect.succeed(0),
reportRunningActionCount: () => Effect.void,
acknowledgeRunningActionQuitWarning: Effect.void,
consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false),
syncAppearance: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]),
),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
getWindowFullscreenState,
openExternal,
probeRemoteEditors,
reportRunningActionCount,
pickFolder,
pickThemeFiles,
setTheme,
Expand All @@ -58,6 +59,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handle(getLocalEnvironmentBearerToken);
yield* ipc.handle(reportRunningActionCount);

yield* ipc.handle(getClientSettings);
yield* ipc.handle(setClientSettings);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const REPORT_RUNNING_ACTION_COUNT_CHANNEL = "desktop:report-running-action-count";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
import * as DesktopWindow from "../../window/DesktopWindow.ts";
import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronMenu from "../../electron/ElectronMenu.ts";
Expand Down Expand Up @@ -145,6 +146,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
}),
});

export const reportRunningActionCount = DesktopIpc.makeIpcMethod({
channel: IpcChannels.REPORT_RUNNING_ACTION_COUNT_CHANNEL,
payload: Schema.Number,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.reportRunningActionCount")(function* (count) {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.reportRunningActionCount(count);
}),
});

// Pull the distro selection out of a backend instance id like
// "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel
// for "track the user's WSL default distro" and maps to the
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstraps"]>;
},
reportRunningActionCount: (count) =>
ipcRenderer.invoke(IpcChannels.REPORT_RUNNING_ACTION_COUNT_CHANNEL, count),
getLocalEnvironmentBearerToken: () =>
ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL),
getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),
Expand Down Expand Up @@ -122,9 +124,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
};
},
onQuitShortcut: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
const wrappedListener = (
_event: Electron.IpcRendererEvent,
state: unknown,
runningActionCount: unknown,
) => {
if (state !== "down" && state !== "up") return;
listener(state);
listener(state, typeof runningActionCount === "number" ? runningActionCount : 0);
};

ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
Expand Down
Loading