Skip to content
Open
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
396 changes: 310 additions & 86 deletions apps/desktop/src/app/DesktopLifecycle.test.ts

Large diffs are not rendered by default.

143 changes: 118 additions & 25 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
Expand All @@ -10,8 +12,11 @@ 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 DesktopClientSettings from "../settings/DesktopClientSettings.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.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";

Expand All @@ -28,15 +33,18 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<Deskt
}

export type DesktopLifecycleRuntimeServices =
| DesktopClientSettings.DesktopClientSettings
| DesktopEnvironment.DesktopEnvironment
| DesktopShutdown.DesktopShutdown
| DesktopState.DesktopState
| DesktopWindow.DesktopWindow
| ElectronApp.ElectronApp
| ElectronTheme.ElectronTheme;
| ElectronDialog.ElectronDialog
| ElectronTheme.ElectronTheme
| ElectronWindow.ElectronWindow;

/**
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme
* @effect-expect-leaking DesktopClientSettings | DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronDialog | ElectronTheme | ElectronWindow
*/
export class DesktopLifecycle extends Context.Service<
DesktopLifecycle,
Expand Down Expand Up @@ -86,13 +94,80 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
},
);

const QUIT_BUTTON_INDEX = 1;

// Quits that no user asked for (a second instance handing over to the running
// one, the last window closing on Windows/Linux, a failed startup) leave no
// window behind, and a prompt there could strand the app with no way back.
const confirmQuitRequested = Effect.fn("desktop.lifecycle.confirmQuitRequested")(
function* (): Effect.fn.Return<
boolean,
never,
| DesktopClientSettings.DesktopClientSettings
| ElectronApp.ElectronApp
| ElectronDialog.ElectronDialog
| ElectronWindow.ElectronWindow
> {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const owner = yield* electronWindow.currentMainOrFirst;
if (Option.isNone(owner)) {
return true;
}

const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
const settings = yield* clientSettings.get;
const confirmQuit = Option.match(settings, {
onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit,
onSome: (value) => value.confirmQuit,
});
if (!confirmQuit) {
yield* logLifecycleInfo("quit confirmation disabled, quitting");
return true;
}

const electronApp = yield* ElectronApp.ElectronApp;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const appName = yield* electronApp.name;
// The dialog is window-modal: a hidden or minimized owner would hide it
// too, leaving a prompt nobody can answer and an app that won't quit.
yield* electronWindow.reveal(owner.value);
const result = yield* electronDialog
.showMessageBox(
{
type: "question",
title: `Quit ${appName}`,
message: `Quit ${appName}?`,
detail: "Running agents and terminals will be stopped.",
buttons: ["Cancel", "Quit"],
defaultId: QUIT_BUTTON_INDEX,
cancelId: 0,
noLink: true,
},
owner,
)
.pipe(
Effect.catch((error: ElectronDialog.ElectronDialogShowMessageBoxError) =>
logLifecycleError("quit confirmation dialog failed", { error }).pipe(
Effect.as({ response: QUIT_BUTTON_INDEX, checkboxChecked: false }),
),
),
);
return result.response === QUIT_BUTTON_INDEX;
},
);

interface QuitGate {
allowed: boolean;
updaterAllowed: boolean;
confirming: boolean;
}

function handleBeforeQuit(
event: Electron.Event,
runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
allowQuit: () => boolean,
markQuitAllowed: () => void,
gate: QuitGate,
): void {
if (allowQuit()) {
if (gate.allowed || gate.updaterAllowed) {
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
Expand All @@ -104,22 +179,48 @@ function handleBeforeQuit(
}

event.preventDefault();
// Quitting again while a confirmation is still up means the user is
// insisting, so honour it instead of asking twice. Swallowing the request
// would strand the app for good if the prompt is never answered.
const skipConfirmation = gate.confirming;
gate.confirming = true;

const quitAfterShutdown = () => {
gate.allowed = true;
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* logLifecycleInfo("shutdown finished, quitting");
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
};

void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
const wasQuitting = yield* Ref.get(state.quitting);
if (!skipConfirmation && !wasQuitting && !(yield* confirmQuitRequested())) {
yield* logLifecycleInfo("quit cancelled from confirmation dialog");
return false;
}
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) => {
gate.confirming = false;
if (shouldQuit) {
quitAfterShutdown();
}
},
() => {
gate.confirming = false;
quitAfterShutdown();
},
);
}

function quitFromSignal(
Expand Down Expand Up @@ -175,8 +276,7 @@ export const make = DesktopLifecycle.of({
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
const runEffect = Effect.runPromiseWith(context);
let quitAllowed = false;
let updaterQuitAllowed = false;
const quitGate: QuitGate = { allowed: false, updaterAllowed: false, confirming: false };
yield* electronTheme.onUpdated(() => {
void runEffect(
desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
Expand All @@ -186,22 +286,15 @@ export const make = DesktopLifecycle.of({
// 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;
quitGate.updaterAllowed = 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 || updaterQuitAllowed,
() => {
quitAllowed = true;
},
);
handleBeforeQuit(event, runEffect, quitGate);
});
yield* electronApp.on("activate", () => {
void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export class ElectronDialog extends Context.Service<
) => Effect.Effect<boolean, ElectronDialogConfirmError>;
readonly showMessageBox: (
options: Electron.MessageBoxOptions,
owner?: Option.Option<Electron.BrowserWindow>,
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
}
Expand Down Expand Up @@ -170,9 +171,13 @@ export const make = ElectronDialog.of({
});
return result.response === CONFIRM_BUTTON_INDEX;
}),
showMessageBox: (options) =>
showMessageBox: (options, owner = Option.none()) =>
Effect.tryPromise({
try: () => Electron.dialog.showMessageBox(options),
try: () =>
Option.match(owner, {
onNone: () => Electron.dialog.showMessageBox(options),
onSome: (ownerWindow) => Electron.dialog.showMessageBox(ownerWindow, options),
}),
catch: (cause) =>
new ElectronDialogShowMessageBoxError({
type: options.type ?? null,
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/ipc/methods/wsl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
import * as DesktopState from "../../app/DesktopState.ts";
import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
import * as DesktopWindow from "../../window/DesktopWindow.ts";
import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(
ElectronTheme.ElectronTheme,
ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]),
),
Layer.succeed(
ElectronDialog.ElectronDialog,
ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]),
),
Layer.succeed(
ElectronWindow.ElectronWindow,
ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]),
),
DesktopClientSettings.layerTest(),
);

describe("WSL IPC", () => {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
confirmQuit: true,
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -650,12 +650,16 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
? ["Delete confirmation"]
: []),
...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit
? ["Quit confirmation"]
: []),
...(isTextGenerationModelDirty ? ["Text generation model"] : []),
],
[
isTextGenerationModelDirty,
isBackgroundActivityDirty,
settings.autoOpenPlanSidebar,
settings.confirmQuit,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
Expand Down Expand Up @@ -713,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) {
addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
Expand Down Expand Up @@ -2079,6 +2084,30 @@ export function GeneralSettingsPanel() {
}
/>

{isElectron ? (
<SettingsRow
{...searchableSetting("quit-confirmation")}
description="Ask before quitting the desktop app."
resetAction={
settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
<SettingResetButton
label="quit confirmation"
onClick={() =>
updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit })
}
/>
) : null
}
control={
<Switch
checked={settings.confirmQuit}
onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })}
aria-label="Confirm quitting"
/>
}
/>
) : null}

<SettingsRow
{...searchableSetting("text-generation-model")}
description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model."
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ describe("searchSettings", () => {
expect(searchSettings(" ", ITEMS)).toEqual([]);
});

it("hides desktop-only settings from browser search", () => {
expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
expect(searchSettings("quit confirmation")).toEqual([]);
});

it("keeps catalog result ids unique", () => {
const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isElectron } from "~/env";

export type SettingsPath =
| "/settings/general"
| "/settings/appearance"
Expand All @@ -13,6 +15,9 @@ export interface SettingsSearchItem {
readonly title: string;
readonly to: SettingsPath;
readonly targetId?: string;
// Its row only renders in the desktop app, so a browser result would land on
// an anchor that isn't there.
readonly desktopOnly?: boolean;
}

/**
Expand Down Expand Up @@ -141,6 +146,12 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Delete confirmation",
to: "/settings/general",
},
{
id: "quit-confirmation",
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
title: "Quit confirmation",
to: "/settings/general",
desktopOnly: true,
},
{
id: "text-generation-model",
title: "Text generation model",
Expand Down Expand Up @@ -224,5 +235,9 @@ export function searchSettings(
const normalizedQuery = normalizeSearchText(query);
if (normalizedQuery.length === 0) return [];

return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery));
return items.filter(
(item) =>
(isElectron || item.desktopOnly !== true) &&
normalizeSearchText(item.title).includes(normalizedQuery),
);
}
Loading
Loading