diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index be9d7f3451f..d00344a8d09 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -1,119 +1,214 @@ import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts"; 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 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 ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.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"; +type AppListeners = Map void>; + +const mainWindow = { id: 1 } as Electron.BrowserWindow; + +interface Harness { + readonly listeners: AppListeners; + readonly quitCalls: Array; + readonly messageBoxes: Array<{ + readonly options: Electron.MessageBoxOptions; + readonly owner: Option.Option; + }>; + readonly revealedWindows: Array; +} + +// The before-quit handler resolves through promise callbacks, so tests wait a +// macrotask for the confirmation and shutdown chain to settle. +const settle = Effect.promise( + () => + new Promise((resolve) => { + setImmediate(resolve); + }), +); + +function makeElectronAppLayer(harness: Harness) { + return Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.sync(() => { + harness.quitCalls.push("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) => + Effect.acquireRelease( + Effect.sync(() => { + harness.listeners.set("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + harness.listeners.delete("before-quit-for-update"); + }), + ).pipe(Effect.asVoid), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + harness.listeners.set( + eventName, + listener as unknown as (...args: readonly unknown[]) => void, + ); + }), + () => + Effect.sync(() => { + harness.listeners.delete(eventName); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronApp.ElectronApp["Service"]); +} + +function makeLayer(input: { + readonly harness: Harness; + readonly platform: NodeJS.Platform; + readonly confirmResponse: number; + readonly hasWindow: boolean; + readonly clientSettings: ClientSettings; +}) { + const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }); + + const electronDialogLayer = Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({ + pickFolder: () => Effect.succeed(Option.none()), + confirm: () => Effect.succeed(false), + showMessageBox: (options, owner = Option.none()) => + Effect.sync(() => { + input.harness.messageBoxes.push({ options, owner }); + return { response: input.confirmResponse, checkboxChecked: false }; + }), + showErrorBox: () => Effect.void, + }), + ); + + const electronWindowLayer = Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + currentMainOrFirst: Effect.succeed(input.hasWindow ? Option.some(mainWindow) : Option.none()), + reveal: (window) => + Effect.sync(() => { + input.harness.revealedWindows.push(window); + }), + } as ElectronWindow.ElectronWindow["Service"]), + ); + + 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: input.platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const shutdownLayer = Layer.succeed( + DesktopShutdown.DesktopShutdown, + DesktopShutdown.DesktopShutdown.of({ + request: Effect.void, + awaitRequest: Effect.void, + markComplete: Effect.void, + awaitComplete: Effect.void, + isComplete: Effect.succeed(true), + }), + ); + + return DesktopLifecycle.layer.pipe( + Layer.provideMerge(makeElectronAppLayer(input.harness)), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronWindowLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(shutdownLayer), + Layer.provideMerge(DesktopClientSettings.layerTest(Option.some(input.clientSettings))), + Layer.provideMerge(DesktopState.layer), + ); +} + +function makeHarness(): Harness { + return { listeners: new Map(), quitCalls: [], messageBoxes: [], revealedWindows: [] }; +} + +function emitBeforeQuit(listeners: AppListeners): boolean { + let prevented = false; + const event = { + preventDefault: () => { + prevented = true; + }, + } as Electron.Event; + listeners.get("before-quit")?.(event); + return prevented; +} + describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { - const appListeners = new Map 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, - 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 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, { + const harness = makeHarness(); + const layer = makeLayer({ + harness, 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), - ); + confirmResponse: 0, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); 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); + harness.listeners.get("before-quit-for-update")?.(); + const prevented = emitBeforeQuit(harness.listeners); assert.isFalse( prevented, "cancelling this event prevents the updater from completing its relaunch", ); + assert.deepEqual(harness.messageBoxes, []); const state = yield* DesktopState.DesktopState; assert.isTrue(yield* Ref.get(state.quitting)); @@ -121,4 +216,133 @@ describe("DesktopLifecycle", () => { ).pipe(Effect.provide(layer)); }); } + + it.effect("keeps the app running when the quit confirmation is dismissed", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 0, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.messageBoxes[0]?.owner, Option.some(mainWindow)); + assert.deepEqual(harness.revealedWindows, [mainWindow]); + assert.deepEqual(harness.quitCalls, []); + const state = yield* DesktopState.DesktopState; + assert.isFalse(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("shuts down and quits once the confirmation is accepted", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 1, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.quitCalls, ["quit"]); + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("quits without asking when the confirmation setting is off", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 0, + hasWindow: true, + clientSettings: { ...DEFAULT_CLIENT_SETTINGS, confirmQuit: false }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.deepEqual(harness.messageBoxes, []); + assert.deepEqual(harness.quitCalls, ["quit"]); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("quits without asking when no window is open", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "win32", + confirmResponse: 0, + hasWindow: false, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.deepEqual(harness.messageBoxes, []); + assert.deepEqual(harness.quitCalls, ["quit"]); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("quits without asking again when the user insists mid-confirmation", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 0, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.quitCalls, ["quit"]); + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ab03d18f38d..713d369454d 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -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"; @@ -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"; @@ -28,15 +33,18 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass { + 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: (effect: Effect.Effect) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + gate: QuitGate, ): void { - if (allowQuit()) { + if (gate.allowed || gate.updaterAllowed) { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; @@ -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( @@ -175,8 +276,7 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); 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")), @@ -186,7 +286,7 @@ 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"), @@ -194,14 +294,7 @@ export const make = DesktopLifecycle.of({ ); }); 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"))); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea..1d922eb1e93 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -97,6 +97,7 @@ export class ElectronDialog extends Context.Service< ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, + owner?: Option.Option, ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } @@ -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, diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f39b..38435e286fa 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -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"; @@ -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", () => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f2191..d2b1d2d6d32 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe319568..2907323bf88 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -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, @@ -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, @@ -2079,6 +2084,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Confirm quitting" + /> + } + /> + ) : null} + { 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); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1ba231a5835..fc5a75b9025 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -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; } /** @@ -141,6 +146,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Delete confirmation", to: "/settings/general", }, + { + id: "quit-confirmation", + title: "Quit confirmation", + to: "/settings/general", + desktopOnly: true, + }, { id: "text-generation-model", title: "Text generation model", @@ -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), + ); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb..ebe5ccc902e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,9 @@ export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Desktop-only: ask before the app quits (Cmd/Ctrl+Q, the Quit menu item, + // or any other app-level quit). Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -748,6 +751,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),