| author | |
| committer | |
| log | b7dbbbaf6c394621cba57cf58dfcc1845f445ef6 |
| tree | cc2ffd993ca66a33dc89e9e1c52fd21037106182 |
| parent | 178da6bc3210b82c4a83f33c8b149f623a3375e1 |
| signature | Signed by PGP key B5690EEEBB952194 |
16 files changed, 539 insertions(+), 3 deletions(-)
apps/desktop/src/ipc/channels.ts+1| ... | ... | @@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | 6 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; |
| 7 | 7 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 8 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; | |
| 8 | 9 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
| 9 | 10 | export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; |
| 10 | 11 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
apps/desktop/src/ipc/methods/wsl.test.ts+12| ... | ... | @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; |
| 10 | 10 | import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; |
| 11 | 11 | import * as DesktopState from "../../app/DesktopState.ts"; |
| 12 | 12 | import * as ElectronApp from "../../electron/ElectronApp.ts"; |
| 13 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | |
| 13 | 14 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; |
| 15 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | |
| 14 | 16 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; |
| 17 | import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; | |
| 15 | 18 | import * as DesktopWindow from "../../window/DesktopWindow.ts"; |
| 16 | 19 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; |
| 17 | 20 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; |
| ... | ... | @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( |
| 70 | 73 | ElectronTheme.ElectronTheme, |
| 71 | 74 | ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), |
| 72 | 75 | ), |
| 76 | Layer.succeed( | |
| 77 | ElectronDialog.ElectronDialog, | |
| 78 | ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), | |
| 79 | ), | |
| 80 | Layer.succeed( | |
| 81 | ElectronWindow.ElectronWindow, | |
| 82 | ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), | |
| 83 | ), | |
| 84 | DesktopClientSettings.layerTest(), | |
| 73 | 85 | ); |
| 74 | 86 | |
| 75 | 87 | describe("WSL IPC", () => { |
apps/desktop/src/preload.ts+11| ... | ... | @@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 117 | 117 | ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); |
| 118 | 118 | }; |
| 119 | 119 | }, |
| 120 | onQuitShortcut: (listener) => { | |
| 121 | const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { | |
| 122 | if (state !== "down" && state !== "up") return; | |
| 123 | listener(state); | |
| 124 | }; | |
| 125 | ||
| 126 | ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); | |
| 127 | return () => { | |
| 128 | ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); | |
| 129 | }; | |
| 130 | }, | |
| 120 | 131 | getWindowFullscreenState: () => |
| 121 | 132 | ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, |
| 122 | 133 | onWindowFullscreenStateChange: (listener) => { |
apps/desktop/src/settings/DesktopClientSettings.test.ts+1| ... | ... | @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 13 | import * as DesktopClientSettings from "./DesktopClientSettings.ts"; |
| 14 | 14 | |
| 15 | 15 | const clientSettings: ClientSettings = { |
| 16 | confirmQuit: true, | |
| 16 | 17 | confirmThreadArchive: true, |
| 17 | 18 | confirmThreadDelete: false, |
| 18 | 19 | dismissedProviderUpdateNotificationKeys: [], |
apps/desktop/src/window/DesktopWindow.test.ts+14| ... | ... | @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 37 | 37 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 38 | 38 | import * as DesktopState from "../app/DesktopState.ts"; |
| 39 | 39 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 40 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; | |
| 41 | import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 40 | 42 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 41 | 43 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 42 | 44 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| ... | ... | @@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { |
| 128 | 130 | }; |
| 129 | 131 | } |
| 130 | 132 | |
| 133 | const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ | |
| 134 | get: Effect.succeed(Option.none()), | |
| 135 | }); | |
| 136 | ||
| 137 | const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ | |
| 138 | quit: Effect.void, | |
| 139 | }); | |
| 140 | ||
| 131 | 141 | const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { |
| 132 | 142 | iconPaths: Effect.succeed({ |
| 133 | 143 | ico: Option.none<string>(), |
| ... | ... | @@ -253,8 +263,10 @@ function makeTestLayer(input: { |
| 253 | 263 | desktopAssetsLayer, |
| 254 | 264 | desktopEnvironmentLayer, |
| 255 | 265 | desktopAppSettingsLayer, |
| 266 | desktopClientSettingsLayer, | |
| 256 | 267 | desktopServerExposureLayer, |
| 257 | 268 | DesktopState.layer, |
| 269 | electronAppLayer, | |
| 258 | 270 | electronMenuLayer, |
| 259 | 271 | Layer.succeed(ElectronShell.ElectronShell, { |
| 260 | 272 | openExternal: (url) => |
| ... | ... | @@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 356 | 368 | desktopAssetsLayer, |
| 357 | 369 | desktopEnvironmentLayer, |
| 358 | 370 | DesktopAppSettings.layerTest(), |
| 371 | desktopClientSettingsLayer, | |
| 359 | 372 | desktopServerExposureLayer, |
| 373 | electronAppLayer, | |
| 360 | 374 | electronMenuLayer, |
| 361 | 375 | Layer.succeed(ElectronShell.ElectronShell, { |
| 362 | 376 | openExternal: () => Effect.succeed(true), |
apps/desktop/src/window/DesktopWindow.ts+39-1| ... | ... | @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; |
| 8 | 8 | |
| 9 | 9 | import * as Electron from "electron"; |
| 10 | 10 | |
| 11 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; | |
| 12 | ||
| 11 | 13 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 12 | 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 15 | import { makeComponentLogger } from "../app/DesktopObservability.ts"; |
| ... | ... | @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; |
| 16 | 18 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 17 | 19 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 18 | 20 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 19 | import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; | |
| 21 | import { | |
| 22 | MENU_ACTION_CHANNEL, | |
| 23 | QUIT_SHORTCUT_CHANNEL, | |
| 24 | WINDOW_FULLSCREEN_STATE_CHANNEL, | |
| 25 | } from "../ipc/channels.ts"; | |
| 20 | 26 | import * as PreviewManager from "../preview/Manager.ts"; |
| 21 | 27 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 28 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; | |
| 29 | import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 30 | import { makeQuitHoldHandler } from "./QuitHold.ts"; | |
| 22 | 31 | |
| 23 | 32 | const TITLEBAR_HEIGHT = 40; |
| 24 | 33 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| ... | ... | @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = |
| 51 | 60 | | DesktopEnvironment.DesktopEnvironment |
| 52 | 61 | | DesktopAssets.DesktopAssets |
| 53 | 62 | | DesktopAppSettings.DesktopAppSettings |
| 63 | | DesktopClientSettings.DesktopClientSettings | |
| 64 | | ElectronApp.ElectronApp | |
| 54 | 65 | | ElectronMenu.ElectronMenu |
| 55 | 66 | | ElectronShell.ElectronShell |
| 56 | 67 | | ElectronTheme.ElectronTheme |
| ... | ... | @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { |
| 261 | 272 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 262 | 273 | const previewManager = yield* PreviewManager.PreviewManager; |
| 263 | 274 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 275 | const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 276 | const electronApp = yield* ElectronApp.ElectronApp; | |
| 264 | 277 | // Window-side latch for the primary backend's readiness. Set by |
| 265 | 278 | // handleBackendReady (driven by the pool's onReady callback), cleared |
| 266 | 279 | // by handleBackendNotReady (driven by onShutdown). Only consumed by |
| ... | ... | @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { |
| 533 | 546 | // close-terminal shortcut can outlive the terminal that handled its first |
| 534 | 547 | // press, so reject repeats before they reach the native window accelerator. |
| 535 | 548 | // Deliberate presses still flow through the renderer or native menu. |
| 549 | // Chrome-style hold-to-quit: intercept the quit accelerator before the | |
| 550 | // native menu sees it and only quit after the shortcut is held. The | |
| 551 | // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. | |
| 552 | const quitHoldHandler = makeQuitHoldHandler({ | |
| 553 | platform: environment.platform, | |
| 554 | isEnabled: () => | |
| 555 | runPromise( | |
| 556 | Effect.map( | |
| 557 | clientSettings.get, | |
| 558 | Option.match({ | |
| 559 | onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, | |
| 560 | onSome: (settings) => settings.confirmQuit, | |
| 561 | }), | |
| 562 | ), | |
| 563 | ), | |
| 564 | notify: (state) => { | |
| 565 | if (!window.isDestroyed()) { | |
| 566 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); | |
| 567 | } | |
| 568 | }, | |
| 569 | quit: () => { | |
| 570 | void runPromise(electronApp.quit); | |
| 571 | }, | |
| 572 | }); | |
| 536 | 573 | window.webContents.on("before-input-event", (event, input) => { |
| 574 | quitHoldHandler(event, input); | |
| 537 | 575 | if (input.type !== "keyDown" || !input.isAutoRepeat) return; |
| 538 | 576 | const modifier = environment.platform === "darwin" ? input.meta : input.control; |
| 539 | 577 | if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { |
apps/desktop/src/window/QuitHold.test.ts created+201| ... | ... | @@ -0,0 +1,201 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | makeQuitHoldHandler, | |
| 5 | QUIT_DOUBLE_TAP_MS, | |
| 6 | QUIT_HOLD_DURATION_MS, | |
| 7 | QUIT_HOLD_RELEASE_GRACE_MS, | |
| 8 | } from "./QuitHold.ts"; | |
| 9 | import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; | |
| 10 | ||
| 11 | function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput { | |
| 12 | return { | |
| 13 | type: "keyDown", | |
| 14 | key: "q", | |
| 15 | meta: true, | |
| 16 | control: false, | |
| 17 | alt: false, | |
| 18 | shift: false, | |
| 19 | isAutoRepeat: false, | |
| 20 | ...overrides, | |
| 21 | }; | |
| 22 | } | |
| 23 | ||
| 24 | function makeHarness(options?: { | |
| 25 | enabled?: boolean; | |
| 26 | platform?: NodeJS.Platform; | |
| 27 | isEnabled?: () => Promise<boolean>; | |
| 28 | }) { | |
| 29 | const notifications: Array<QuitHoldState> = []; | |
| 30 | const quit = vi.fn(); | |
| 31 | const handler = makeQuitHoldHandler({ | |
| 32 | platform: options?.platform ?? "darwin", | |
| 33 | isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), | |
| 34 | notify: (state) => notifications.push(state), | |
| 35 | quit, | |
| 36 | }); | |
| 37 | const preventDefault = vi.fn(); | |
| 38 | const send = async (input: QuitHoldKeyInput) => { | |
| 39 | handler({ preventDefault }, input); | |
| 40 | // Let the isEnabled promise settle. | |
| 41 | await Promise.resolve(); | |
| 42 | await Promise.resolve(); | |
| 43 | }; | |
| 44 | // Simulates the OS auto-repeating the held shortcut every `intervalMs`. | |
| 45 | const holdFor = async ( | |
| 46 | durationMs: number, | |
| 47 | repeatOverrides: Partial<QuitHoldKeyInput> = {}, | |
| 48 | intervalMs = 100, | |
| 49 | ) => { | |
| 50 | for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { | |
| 51 | vi.advanceTimersByTime(intervalMs); | |
| 52 | await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); | |
| 53 | } | |
| 54 | }; | |
| 55 | return { notifications, quit, preventDefault, send, holdFor }; | |
| 56 | } | |
| 57 | ||
| 58 | describe("makeQuitHoldHandler", () => { | |
| 59 | beforeEach(() => { | |
| 60 | vi.useFakeTimers(); | |
| 61 | }); | |
| 62 | afterEach(() => { | |
| 63 | vi.useRealTimers(); | |
| 64 | }); | |
| 65 | ||
| 66 | it("shows the hint on a tap without quitting, even when the release is never seen", async () => { | |
| 67 | // macOS suppresses the letter's keyUp while Cmd is held, so a tap may | |
| 68 | // produce no keyUp at all. Quit must still not fire. | |
| 69 | const harness = makeHarness(); | |
| 70 | await harness.send(makeInput({})); | |
| 71 | expect(harness.preventDefault).toHaveBeenCalledTimes(1); | |
| 72 | expect(harness.notifications).toEqual(["down"]); | |
| 73 | ||
| 74 | vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); | |
| 75 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 76 | // The watchdog dismisses the hint once the press is clearly over. | |
| 77 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 78 | }); | |
| 79 | ||
| 80 | it("quits once the shortcut auto-repeats past the hold duration", async () => { | |
| 81 | const harness = makeHarness(); | |
| 82 | await harness.send(makeInput({})); | |
| 83 | await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); | |
| 84 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 85 | await harness.holdFor(400); | |
| 86 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 87 | // Exactly one hint cycle for the whole hold. | |
| 88 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 89 | }); | |
| 90 | ||
| 91 | it("does not quit when the hold stops before the duration", async () => { | |
| 92 | const harness = makeHarness(); | |
| 93 | await harness.send(makeInput({})); | |
| 94 | await harness.holdFor(500); | |
| 95 | await harness.send(makeInput({ type: "keyUp" })); | |
| 96 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 97 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); | |
| 98 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 99 | }); | |
| 100 | ||
| 101 | it("cancels the hold when the modifier is released first", async () => { | |
| 102 | const harness = makeHarness(); | |
| 103 | await harness.send(makeInput({})); | |
| 104 | await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); | |
| 105 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 106 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); | |
| 107 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 108 | }); | |
| 109 | ||
| 110 | it("quits immediately on a single press when disabled", async () => { | |
| 111 | const harness = makeHarness({ enabled: false }); | |
| 112 | await harness.send(makeInput({})); | |
| 113 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 114 | // The hint is dismissed in case the quit gets cancelled downstream. | |
| 115 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 116 | }); | |
| 117 | ||
| 118 | it("discards a stale isEnabled resolution from a superseded press", async () => { | |
| 119 | // Press #1's isEnabled is still pending when the user releases and | |
| 120 | // presses again; its late resolution must not act for press #2. | |
| 121 | const resolvers: Array<(enabled: boolean) => void> = []; | |
| 122 | const harness = makeHarness({ | |
| 123 | isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), | |
| 124 | }); | |
| 125 | await harness.send(makeInput({})); | |
| 126 | await harness.send(makeInput({ type: "keyUp" })); | |
| 127 | // Outside the double-tap window, so the second press starts a new hold. | |
| 128 | vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); | |
| 129 | await harness.send(makeInput({})); | |
| 130 | expect(resolvers).toHaveLength(2); | |
| 131 | ||
| 132 | // Press #1 resolves late with "disabled" — it must not quit press #2. | |
| 133 | resolvers[0]?.(false); | |
| 134 | await Promise.resolve(); | |
| 135 | await Promise.resolve(); | |
| 136 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 137 | ||
| 138 | // Press #2 resolves enabled and completes a full hold. | |
| 139 | resolvers[1]?.(true); | |
| 140 | await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); | |
| 141 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 142 | }); | |
| 143 | ||
| 144 | it("quits on a quick double tap, even when the first release was never seen", async () => { | |
| 145 | const harness = makeHarness(); | |
| 146 | await harness.send(makeInput({})); | |
| 147 | vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); | |
| 148 | await harness.send(makeInput({})); | |
| 149 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 150 | }); | |
| 151 | ||
| 152 | it("treats two slow taps as separate presses", async () => { | |
| 153 | const harness = makeHarness(); | |
| 154 | await harness.send(makeInput({})); | |
| 155 | await harness.send(makeInput({ type: "keyUp" })); | |
| 156 | vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); | |
| 157 | await harness.send(makeInput({})); | |
| 158 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 159 | expect(harness.notifications).toEqual(["down", "up", "down"]); | |
| 160 | }); | |
| 161 | ||
| 162 | it("cancels the hold when another key interrupts it", async () => { | |
| 163 | const harness = makeHarness(); | |
| 164 | await harness.send(makeInput({})); | |
| 165 | await harness.holdFor(500); | |
| 166 | // Shift pressed mid-hold breaks the gesture... | |
| 167 | await harness.send(makeInput({ shift: true })); | |
| 168 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 169 | // ...so later repeats past the threshold must not quit. | |
| 170 | await harness.holdFor(QUIT_HOLD_DURATION_MS); | |
| 171 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 172 | }); | |
| 173 | ||
| 174 | it("does not count an interrupted press toward a double tap", async () => { | |
| 175 | const harness = makeHarness(); | |
| 176 | await harness.send(makeInput({})); | |
| 177 | await harness.send(makeInput({ shift: true })); | |
| 178 | // A fresh press right after the interruption starts a new hold, not a | |
| 179 | // double-tap quit. | |
| 180 | await harness.send(makeInput({})); | |
| 181 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 182 | expect(harness.notifications).toEqual(["down", "up", "down"]); | |
| 183 | }); | |
| 184 | ||
| 185 | it("ignores other shortcuts", async () => { | |
| 186 | const harness = makeHarness(); | |
| 187 | await harness.send(makeInput({ key: "w" })); | |
| 188 | await harness.send(makeInput({ shift: true })); | |
| 189 | await harness.send(makeInput({ meta: false })); | |
| 190 | expect(harness.preventDefault).not.toHaveBeenCalled(); | |
| 191 | expect(harness.notifications).toEqual([]); | |
| 192 | }); | |
| 193 | ||
| 194 | it("uses control on non-mac platforms", async () => { | |
| 195 | const harness = makeHarness({ platform: "linux" }); | |
| 196 | await harness.send(makeInput({ meta: false, control: true })); | |
| 197 | expect(harness.preventDefault).toHaveBeenCalledTimes(1); | |
| 198 | await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); | |
| 199 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 200 | }); | |
| 201 | }); |
apps/desktop/src/window/QuitHold.ts created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | // @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. | |
| 2 | ||
| 3 | // Chrome-style hold-to-quit. The quit accelerator is intercepted in | |
| 4 | // before-input-event (which runs before the native menu accelerator), and the | |
| 5 | // app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. | |
| 6 | // A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap | |
| 7 | // within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application | |
| 8 | // menu itself is untouched and quits immediately. | |
| 9 | export const QUIT_HOLD_DURATION_MS = 1200; | |
| 10 | // A second quick tap of the shortcut is the user insisting: quit immediately. | |
| 11 | export const QUIT_DOUBLE_TAP_MS = 500; | |
| 12 | // "Still held" is proven by auto-repeat keydowns, not by the absence of a | |
| 13 | // release: macOS suppresses a letter's keyUp while the command key is down, so | |
| 14 | // a tap's release can go completely unseen and a release-based timer would | |
| 15 | // quit anyway. The press is treated as released once no key event has arrived | |
| 16 | // for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with | |
| 17 | // auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. | |
| 18 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; | |
| 19 | ||
| 20 | export type QuitHoldState = "down" | "up"; | |
| 21 | ||
| 22 | export interface QuitHoldKeyInput { | |
| 23 | readonly type: string; | |
| 24 | readonly key: string; | |
| 25 | readonly meta: boolean; | |
| 26 | readonly control: boolean; | |
| 27 | readonly alt: boolean; | |
| 28 | readonly shift: boolean; | |
| 29 | readonly isAutoRepeat: boolean; | |
| 30 | } | |
| 31 | ||
| 32 | export interface QuitHoldOptions { | |
| 33 | readonly platform: NodeJS.Platform; | |
| 34 | readonly isEnabled: () => Promise<boolean>; | |
| 35 | readonly notify: (state: QuitHoldState) => void; | |
| 36 | readonly quit: () => void; | |
| 37 | } | |
| 38 | ||
| 39 | export function makeQuitHoldHandler( | |
| 40 | options: QuitHoldOptions, | |
| 41 | ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { | |
| 42 | const modifierKey = options.platform === "darwin" ? "meta" : "control"; | |
| 43 | let watchdog: NodeJS.Timeout | undefined; | |
| 44 | let holding = false; | |
| 45 | // Set once isEnabled resolves true; auto-repeats may only quit when armed. | |
| 46 | let armed = false; | |
| 47 | let heldSince = 0; | |
| 48 | let lastPressAt = 0; | |
| 49 | // Incremented on every new press and every release/quit so a pending | |
| 50 | // isEnabled() resolution from a superseded press cannot arm (or quit for) | |
| 51 | // the current one. | |
| 52 | let generation = 0; | |
| 53 | ||
| 54 | const clearWatchdog = () => { | |
| 55 | if (watchdog !== undefined) { | |
| 56 | clearTimeout(watchdog); | |
| 57 | watchdog = undefined; | |
| 58 | } | |
| 59 | }; | |
| 60 | ||
| 61 | const release = () => { | |
| 62 | if (!holding) return; | |
| 63 | generation += 1; | |
| 64 | holding = false; | |
| 65 | armed = false; | |
| 66 | clearWatchdog(); | |
| 67 | options.notify("up"); | |
| 68 | }; | |
| 69 | ||
| 70 | // Dismisses the overlay first: if the quit is cancelled downstream the | |
| 71 | // renderer must not be left with a stuck "Hold to Quit" hint. | |
| 72 | const quitNow = () => { | |
| 73 | release(); | |
| 74 | options.quit(); | |
| 75 | }; | |
| 76 | ||
| 77 | return (event, input) => { | |
| 78 | const key = input.key.toLowerCase(); | |
| 79 | if (input.type === "keyUp") { | |
| 80 | if (key === "q" || key === modifierKey) release(); | |
| 81 | return; | |
| 82 | } | |
| 83 | if (input.type !== "keyDown") return; | |
| 84 | ||
| 85 | const modifierDown = options.platform === "darwin" ? input.meta : input.control; | |
| 86 | if (!modifierDown || input.alt || input.shift || key !== "q") { | |
| 87 | // Any other key (or an extra modifier) pressed mid-hold breaks the | |
| 88 | // gesture; without this the hold timer keeps running through the | |
| 89 | // interruption and the next qualifying repeat would quit early. The | |
| 90 | // interrupted press also stops counting toward a double tap — but only | |
| 91 | // here, not in release(), which runs mid-restart on an unseen-release | |
| 92 | // re-press and must not wipe that press's own tap timestamp. | |
| 93 | if (holding && !input.isAutoRepeat) { | |
| 94 | lastPressAt = 0; | |
| 95 | release(); | |
| 96 | } | |
| 97 | return; | |
| 98 | } | |
| 99 | ||
| 100 | event.preventDefault(); | |
| 101 | ||
| 102 | if (input.isAutoRepeat) { | |
| 103 | if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { | |
| 104 | quitNow(); | |
| 105 | } | |
| 106 | return; | |
| 107 | } | |
| 108 | ||
| 109 | const now = Date.now(); | |
| 110 | const previousPressAt = lastPressAt; | |
| 111 | lastPressAt = now; | |
| 112 | // A fresh keydown while "holding" means the key came back down after a | |
| 113 | // release macOS never delivered — so both branches below see real taps. | |
| 114 | if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { | |
| 115 | quitNow(); | |
| 116 | return; | |
| 117 | } | |
| 118 | if (holding) release(); | |
| 119 | ||
| 120 | generation += 1; | |
| 121 | const pressGeneration = generation; | |
| 122 | holding = true; | |
| 123 | heldSince = now; | |
| 124 | options.notify("down"); | |
| 125 | void options.isEnabled().then( | |
| 126 | (enabled) => { | |
| 127 | if (generation !== pressGeneration) return; | |
| 128 | if (!enabled) { | |
| 129 | // Hold-to-quit disabled: a single press quits immediately. | |
| 130 | quitNow(); | |
| 131 | return; | |
| 132 | } | |
| 133 | armed = true; | |
| 134 | // No auto-repeat by then means the key was released (possibly with a | |
| 135 | // suppressed keyUp) or repeat is disabled; either way, don't quit. | |
| 136 | watchdog = setTimeout(() => { | |
| 137 | watchdog = undefined; | |
| 138 | release(); | |
| 139 | }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); | |
| 140 | }, | |
| 141 | // A failed settings read must never strand the quit request. | |
| 142 | () => { | |
| 143 | if (generation !== pressGeneration) return; | |
| 144 | quitNow(); | |
| 145 | }, | |
| 146 | ); | |
| 147 | }; | |
| 148 | } |
apps/web/src/AppRoot.test.tsx+3-1| ... | ... | @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 6 | 6 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; |
| 7 | import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; | |
| 7 | 8 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 8 | 9 | import type { AppRouter } from "./router"; |
| 9 | 10 | import { AppRoot } from "./AppRoot"; |
| ... | ... | @@ -16,9 +17,10 @@ describe("AppRoot", () => { |
| 16 | 17 | const children = Children.toArray( |
| 17 | 18 | (root as ReactElement<{ readonly children: ReactNode }>).props.children, |
| 18 | 19 | ); |
| 19 | expect(children).toHaveLength(3); | |
| 20 | expect(children).toHaveLength(4); | |
| 20 | 21 | expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); |
| 21 | 22 | expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); |
| 22 | 23 | expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); |
| 24 | expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); | |
| 23 | 25 | }); |
| 24 | 26 | }); |
apps/web/src/AppRoot.tsx+2| ... | ... | @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 4 | 4 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; |
| 5 | import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; | |
| 5 | 6 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 6 | 7 | import type { AppRouter } from "./router"; |
| 7 | 8 | |
| ... | ... | @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { |
| 16 | 17 | <RouterProvider router={router} /> |
| 17 | 18 | <PreviewAutomationHosts /> |
| 18 | 19 | <ElectronBrowserHost /> |
| 20 | <QuitHoldOverlay /> | |
| 19 | 21 | </AppAtomRegistryProvider> |
| 20 | 22 | ); |
| 21 | 23 | } |
apps/web/src/components/QuitHoldOverlay.tsx created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { useEffect, useState } from "react"; | |
| 2 | ||
| 3 | import { isMacPlatform } from "../lib/utils"; | |
| 4 | ||
| 5 | // Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint | |
| 6 | // from a quick tap lingers for as long as a full hold would have taken. | |
| 7 | const HIDE_AFTER_RELEASE_MS = 1200; | |
| 8 | ||
| 9 | /** | |
| 10 | * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts | |
| 11 | * the quit accelerator and pushes press/release states; a quick tap shows | |
| 12 | * this pill while a full hold quits the app. | |
| 13 | */ | |
| 14 | export function QuitHoldOverlay() { | |
| 15 | const [visible, setVisible] = useState(false); | |
| 16 | ||
| 17 | useEffect(() => { | |
| 18 | const subscribe = window.desktopBridge?.onQuitShortcut; | |
| 19 | if (!subscribe) return; | |
| 20 | let hideTimer: number | undefined; | |
| 21 | const unsubscribe = subscribe((state) => { | |
| 22 | window.clearTimeout(hideTimer); | |
| 23 | if (state === "down") { | |
| 24 | setVisible(true); | |
| 25 | return; | |
| 26 | } | |
| 27 | hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); | |
| 28 | }); | |
| 29 | return () => { | |
| 30 | window.clearTimeout(hideTimer); | |
| 31 | unsubscribe(); | |
| 32 | }; | |
| 33 | }, []); | |
| 34 | ||
| 35 | if (!visible) return null; | |
| 36 | const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; | |
| 37 | return ( | |
| 38 | <div | |
| 39 | role="status" | |
| 40 | className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center" | |
| 41 | > | |
| 42 | <div className="rounded-full bg-neutral-700/95 px-8 py-4 text-2xl font-bold text-white shadow-xl"> | |
| 43 | Hold {shortcut} to Quit | |
| 44 | </div> | |
| 45 | </div> | |
| 46 | ); | |
| 47 | } |
apps/web/src/components/settings/SettingsPanels.tsx+29| ... | ... | @@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 526 | 526 | ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete |
| 527 | 527 | ? ["Delete confirmation"] |
| 528 | 528 | : []), |
| 529 | ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit | |
| 530 | ? ["Quit confirmation"] | |
| 531 | : []), | |
| 529 | 532 | ...(isTextGenerationModelDirty ? ["Text generation model"] : []), |
| 530 | 533 | ], |
| 531 | 534 | [ |
| 532 | 535 | isTextGenerationModelDirty, |
| 533 | 536 | isBackgroundActivityDirty, |
| 537 | settings.confirmQuit, | |
| 534 | 538 | settings.confirmThreadArchive, |
| 535 | 539 | settings.confirmThreadDelete, |
| 536 | 540 | settings.addProjectBaseDirectory, |
| ... | ... | @@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 644 | 648 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| 645 | 649 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
| 646 | 650 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, |
| 651 | confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, | |
| 647 | 652 | textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, |
| 648 | 653 | fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, |
| 649 | 654 | fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, |
| ... | ... | @@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() { |
| 2234 | 2239 | } |
| 2235 | 2240 | /> |
| 2236 | 2241 | |
| 2242 | {isElectron ? ( | |
| 2243 | <SettingsRow | |
| 2244 | {...searchableSetting("quit-confirmation")} | |
| 2245 | description="Require holding the quit shortcut before the desktop app quits. A quick tap shows a hint instead." | |
| 2246 | resetAction={ | |
| 2247 | settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ( | |
| 2248 | <SettingResetButton | |
| 2249 | label="quit confirmation" | |
| 2250 | onClick={() => | |
| 2251 | updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) | |
| 2252 | } | |
| 2253 | /> | |
| 2254 | ) : null | |
| 2255 | } | |
| 2256 | control={ | |
| 2257 | <Switch | |
| 2258 | checked={settings.confirmQuit} | |
| 2259 | onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })} | |
| 2260 | aria-label="Hold to quit" | |
| 2261 | /> | |
| 2262 | } | |
| 2263 | /> | |
| 2264 | ) : null} | |
| 2265 | ||
| 2237 | 2266 | <SettingsRow |
| 2238 | 2267 | {...searchableSetting("text-generation-model")} |
| 2239 | 2268 | 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." |
apps/web/src/components/settings/settingsSearch.test.ts+5| ... | ... | @@ -60,6 +60,11 @@ describe("searchSettings", () => { |
| 60 | 60 | expect(searchSettings(" ", ITEMS)).toEqual([]); |
| 61 | 61 | }); |
| 62 | 62 | |
| 63 | it("hides desktop-only settings from browser search", () => { | |
| 64 | expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); | |
| 65 | expect(searchSettings("quit confirmation")).toEqual([]); | |
| 66 | }); | |
| 67 | ||
| 63 | 68 | it("keeps catalog result ids unique", () => { |
| 64 | 69 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 65 | 70 | expect(new Set(ids).size).toBe(ids.length); |
apps/web/src/components/settings/settingsSearch.ts+16-1| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { isElectron } from "~/env"; | |
| 2 | ||
| 1 | 3 | export type SettingsPath = |
| 2 | 4 | | "/settings/general" |
| 3 | 5 | | "/settings/appearance" |
| ... | ... | @@ -12,6 +14,9 @@ export interface SettingsSearchItem { |
| 12 | 14 | readonly title: string; |
| 13 | 15 | readonly to: SettingsPath; |
| 14 | 16 | readonly targetId?: string; |
| 17 | // Its row only renders in the desktop app, so a browser result would land on | |
| 18 | // an anchor that isn't there. | |
| 19 | readonly desktopOnly?: boolean; | |
| 15 | 20 | } |
| 16 | 21 | |
| 17 | 22 | /** |
| ... | ... | @@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 149 | 154 | title: "Delete confirmation", |
| 150 | 155 | to: "/settings/general", |
| 151 | 156 | }, |
| 157 | { | |
| 158 | id: "quit-confirmation", | |
| 159 | title: "Hold to quit", | |
| 160 | to: "/settings/general", | |
| 161 | desktopOnly: true, | |
| 162 | }, | |
| 152 | 163 | { |
| 153 | 164 | id: "text-generation-model", |
| 154 | 165 | title: "Text generation model", |
| ... | ... | @@ -236,5 +247,9 @@ export function searchSettings( |
| 236 | 247 | const normalizedQuery = normalizeSearchText(query); |
| 237 | 248 | if (normalizedQuery.length === 0) return []; |
| 238 | 249 | |
| 239 | return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); | |
| 250 | return items.filter( | |
| 251 | (item) => | |
| 252 | (isElectron || item.desktopOnly !== true) && | |
| 253 | normalizeSearchText(item.title).includes(normalizedQuery), | |
| 254 | ); | |
| 240 | 255 | } |
packages/contracts/src/ipc.ts+6| ... | ... | @@ -1080,6 +1080,12 @@ export interface DesktopBridge { |
| 1080 | 1080 | */ |
| 1081 | 1081 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1082 | 1082 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1083 | /** | |
| 1084 | * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, | |
| 1085 | * "up" when it is released before the hold completes. Optional: older | |
| 1086 | * desktop builds never emit it. | |
| 1087 | */ | |
| 1088 | onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; | |
| 1083 | 1089 | getWindowFullscreenState: () => boolean; |
| 1084 | 1090 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
| 1085 | 1091 | getUpdateState: () => Promise<DesktopUpdateState>; |
packages/contracts/src/settings.ts+4| ... | ... | @@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) |
| 112 | 112 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; |
| 113 | 113 | |
| 114 | 114 | export const ClientSettingsSchema = Schema.Struct({ |
| 115 | // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the | |
| 116 | // app quits; a quick tap only shows a hint. Browser clients ignore it. | |
| 117 | confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 115 | 118 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 116 | 119 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 117 | 120 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| ... | ... | @@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 756 | 759 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 757 | 760 | |
| 758 | 761 | export const ClientSettingsPatch = Schema.Struct({ |
| 762 | confirmQuit: Schema.optionalKey(Schema.Boolean), | |
| 759 | 763 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 760 | 764 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 761 | 765 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |