| author | |
| committer | |
| log | 9d1879b142a2f5d01383357646a4679d1a2bd202 |
| tree | e7002b9c4102c7f0e7d24a4911e54c5b7e2957a3 |
| parent | ef7014d851f56bb037a9da963095ffd883c7fa08 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 364 insertions(+), 131 deletions(-)
apps/desktop/src/preload.ts+13-3| ... | ... | @@ -128,9 +128,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 128 | 128 | }; |
| 129 | 129 | }, |
| 130 | 130 | onQuitShortcut: (listener) => { |
| 131 | const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { | |
| 132 | if (state !== "down" && state !== "up") return; | |
| 133 | listener(state); | |
| 131 | const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => { | |
| 132 | if (typeof hint !== "object" || hint === null || !("state" in hint)) return; | |
| 133 | if (hint.state === "up") { | |
| 134 | listener({ state: "up" }); | |
| 135 | return; | |
| 136 | } | |
| 137 | if ( | |
| 138 | hint.state === "down" && | |
| 139 | "mode" in hint && | |
| 140 | (hint.mode === "hold" || hint.mode === "double-click") | |
| 141 | ) { | |
| 142 | listener({ state: "down", mode: hint.mode }); | |
| 143 | } | |
| 134 | 144 | }; |
| 135 | 145 | |
| 136 | 146 | ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); |
apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1| ... | ... | @@ -19,7 +19,7 @@ const clientSettings: ClientSettings = { |
| 19 | 19 | browserDefaultAppearance: "dark", |
| 20 | 20 | browserRecordingFrameRate: 60, |
| 21 | 21 | browserAutoShowFloatingPreview: false, |
| 22 | confirmQuit: true, | |
| 22 | confirmQuit: "double-click", | |
| 23 | 23 | confirmThreadArchive: true, |
| 24 | 24 | confirmThreadDelete: false, |
| 25 | 25 | confirmThreadUnpin: false, |
apps/desktop/src/window/DesktopWindow.ts+8-9| ... | ... | @@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts"; |
| 27 | 27 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 28 | 28 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 29 | 29 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 30 | import { makeQuitHoldHandler } from "./QuitHold.ts"; | |
| 30 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; | |
| 31 | 31 | |
| 32 | 32 | const TITLEBAR_HEIGHT = 40; |
| 33 | 33 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| ... | ... | @@ -551,12 +551,11 @@ export const make = Effect.gen(function* () { |
| 551 | 551 | // close-terminal shortcut can outlive the terminal that handled its first |
| 552 | 552 | // press, so reject repeats before they reach the native window accelerator. |
| 553 | 553 | // Deliberate presses still flow through the renderer or native menu. |
| 554 | // Chrome-style hold-to-quit: intercept the quit accelerator before the | |
| 555 | // native menu sees it and only quit after the shortcut is held. The | |
| 556 | // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. | |
| 557 | const quitHoldHandler = makeQuitHoldHandler({ | |
| 554 | // Intercept the quit accelerator before the native menu sees it and apply | |
| 555 | // the configured direct, hold, or double-press behavior. | |
| 556 | const quitShortcutHandler = makeQuitShortcutHandler({ | |
| 558 | 557 | platform: environment.platform, |
| 559 | isEnabled: () => | |
| 558 | getMode: () => | |
| 560 | 559 | runPromise( |
| 561 | 560 | Effect.map( |
| 562 | 561 | clientSettings.get, |
| ... | ... | @@ -566,9 +565,9 @@ export const make = Effect.gen(function* () { |
| 566 | 565 | }), |
| 567 | 566 | ), |
| 568 | 567 | ), |
| 569 | notify: (state) => { | |
| 568 | notify: (hint) => { | |
| 570 | 569 | if (!window.isDestroyed()) { |
| 571 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); | |
| 570 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); | |
| 572 | 571 | } |
| 573 | 572 | }, |
| 574 | 573 | quit: () => { |
| ... | ... | @@ -576,7 +575,7 @@ export const make = Effect.gen(function* () { |
| 576 | 575 | }, |
| 577 | 576 | }); |
| 578 | 577 | window.webContents.on("before-input-event", (event, input) => { |
| 579 | quitHoldHandler(event, input); | |
| 578 | quitShortcutHandler(event, input); | |
| 580 | 579 | if (input.type !== "keyDown" || !input.isAutoRepeat) return; |
| 581 | 580 | const modifier = environment.platform === "darwin" ? input.meta : input.control; |
| 582 | 581 | if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { |
apps/desktop/src/window/QuitHold.test.ts+162-40| ... | ... | @@ -1,12 +1,17 @@ |
| 1 | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | makeQuitHoldHandler, | |
| 5 | QUIT_DOUBLE_TAP_MS, | |
| 4 | makeQuitShortcutHandler, | |
| 5 | QUIT_DOUBLE_PRESS_MS, | |
| 6 | 6 | QUIT_HOLD_DURATION_MS, |
| 7 | 7 | QUIT_HOLD_RELEASE_GRACE_MS, |
| 8 | 8 | } from "./QuitHold.ts"; |
| 9 | import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; | |
| 9 | import type { QuitHoldKeyInput } from "./QuitHold.ts"; | |
| 10 | import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; | |
| 11 | ||
| 12 | const HOLD_DOWN = { state: "down", mode: "hold" } as const; | |
| 13 | const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; | |
| 14 | const UP = { state: "up" } as const; | |
| 10 | 15 | |
| 11 | 16 | function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput { |
| 12 | 17 | return { |
| ... | ... | @@ -22,22 +27,22 @@ function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput { |
| 22 | 27 | } |
| 23 | 28 | |
| 24 | 29 | function makeHarness(options?: { |
| 25 | enabled?: boolean; | |
| 30 | mode?: QuitConfirmationMode; | |
| 26 | 31 | platform?: NodeJS.Platform; |
| 27 | isEnabled?: () => Promise<boolean>; | |
| 32 | getMode?: () => Promise<QuitConfirmationMode>; | |
| 28 | 33 | }) { |
| 29 | const notifications: Array<QuitHoldState> = []; | |
| 34 | const notifications: Array<QuitShortcutHintEvent> = []; | |
| 30 | 35 | const quit = vi.fn(); |
| 31 | const handler = makeQuitHoldHandler({ | |
| 36 | const handler = makeQuitShortcutHandler({ | |
| 32 | 37 | platform: options?.platform ?? "darwin", |
| 33 | isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), | |
| 34 | notify: (state) => notifications.push(state), | |
| 38 | getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), | |
| 39 | notify: (event) => notifications.push(event), | |
| 35 | 40 | quit, |
| 36 | 41 | }); |
| 37 | 42 | const preventDefault = vi.fn(); |
| 38 | 43 | const send = async (input: QuitHoldKeyInput) => { |
| 39 | 44 | handler({ preventDefault }, input); |
| 40 | // Let the isEnabled promise settle. | |
| 45 | // Let the getMode promise settle. | |
| 41 | 46 | await Promise.resolve(); |
| 42 | 47 | await Promise.resolve(); |
| 43 | 48 | }; |
| ... | ... | @@ -55,7 +60,7 @@ function makeHarness(options?: { |
| 55 | 60 | return { notifications, quit, preventDefault, send, holdFor }; |
| 56 | 61 | } |
| 57 | 62 | |
| 58 | describe("makeQuitHoldHandler", () => { | |
| 63 | describe("makeQuitShortcutHandler", () => { | |
| 59 | 64 | beforeEach(() => { |
| 60 | 65 | vi.useFakeTimers(); |
| 61 | 66 | }); |
| ... | ... | @@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => { |
| 69 | 74 | const harness = makeHarness(); |
| 70 | 75 | await harness.send(makeInput({})); |
| 71 | 76 | expect(harness.preventDefault).toHaveBeenCalledTimes(1); |
| 72 | expect(harness.notifications).toEqual(["down"]); | |
| 77 | expect(harness.notifications).toEqual([HOLD_DOWN]); | |
| 73 | 78 | |
| 74 | 79 | vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); |
| 75 | 80 | expect(harness.quit).not.toHaveBeenCalled(); |
| 76 | 81 | // The watchdog dismisses the hint once the press is clearly over. |
| 77 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 82 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | |
| 78 | 83 | }); |
| 79 | 84 | |
| 80 | 85 | it("quits after a completed hold is released", async () => { |
| ... | ... | @@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => { |
| 86 | 91 | expect(harness.quit).not.toHaveBeenCalled(); |
| 87 | 92 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); |
| 88 | 93 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 89 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 94 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | |
| 90 | 95 | }); |
| 91 | 96 | |
| 92 | 97 | it("waits for Q release when Cmd is released first", async () => { |
| ... | ... | @@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => { |
| 108 | 113 | await harness.send(makeInput({})); |
| 109 | 114 | await harness.holdFor(500); |
| 110 | 115 | await harness.send(makeInput({ type: "keyUp" })); |
| 111 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 116 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | |
| 112 | 117 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); |
| 113 | 118 | expect(harness.quit).not.toHaveBeenCalled(); |
| 114 | 119 | }); |
| ... | ... | @@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => { |
| 117 | 122 | const harness = makeHarness(); |
| 118 | 123 | await harness.send(makeInput({})); |
| 119 | 124 | await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); |
| 120 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 125 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | |
| 121 | 126 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); |
| 122 | 127 | expect(harness.quit).not.toHaveBeenCalled(); |
| 123 | 128 | }); |
| 124 | 129 | |
| 125 | it("quits without showing a hint when hold-to-quit is disabled", async () => { | |
| 126 | const harness = makeHarness({ enabled: false }); | |
| 130 | it("quits without showing a hint in direct mode", async () => { | |
| 131 | const harness = makeHarness({ mode: "direct" }); | |
| 127 | 132 | await harness.send(makeInput({})); |
| 128 | 133 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 129 | 134 | expect(harness.notifications).toEqual([]); |
| 130 | 135 | }); |
| 131 | 136 | |
| 132 | it("discards a stale isEnabled resolution from a superseded press", async () => { | |
| 133 | // Press #1's isEnabled is still pending when the user releases and | |
| 137 | it("honors direct mode when the key is released before its mode read settles", async () => { | |
| 138 | let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; | |
| 139 | const harness = makeHarness({ | |
| 140 | getMode: () => | |
| 141 | new Promise((resolve) => { | |
| 142 | resolveMode = resolve; | |
| 143 | }), | |
| 144 | }); | |
| 145 | await harness.send(makeInput({})); | |
| 146 | await harness.send(makeInput({ type: "keyUp" })); | |
| 147 | ||
| 148 | resolveMode?.("direct"); | |
| 149 | await Promise.resolve(); | |
| 150 | await Promise.resolve(); | |
| 151 | ||
| 152 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 153 | expect(harness.notifications).toEqual([]); | |
| 154 | }); | |
| 155 | ||
| 156 | it("does not arm hold mode after a released key's mode read settles", async () => { | |
| 157 | let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; | |
| 158 | const harness = makeHarness({ | |
| 159 | getMode: () => | |
| 160 | new Promise((resolve) => { | |
| 161 | resolveMode = resolve; | |
| 162 | }), | |
| 163 | }); | |
| 164 | await harness.send(makeInput({})); | |
| 165 | await harness.send(makeInput({ type: "keyUp" })); | |
| 166 | ||
| 167 | resolveMode?.("hold"); | |
| 168 | await Promise.resolve(); | |
| 169 | await Promise.resolve(); | |
| 170 | ||
| 171 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 172 | expect(harness.notifications).toEqual([]); | |
| 173 | }); | |
| 174 | ||
| 175 | it("honors a quick double press when both key releases beat their mode reads", async () => { | |
| 176 | const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; | |
| 177 | const harness = makeHarness({ | |
| 178 | getMode: () => new Promise((resolve) => resolvers.push(resolve)), | |
| 179 | }); | |
| 180 | await harness.send(makeInput({})); | |
| 181 | await harness.send(makeInput({ type: "keyUp" })); | |
| 182 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); | |
| 183 | await harness.send(makeInput({})); | |
| 184 | await harness.send(makeInput({ type: "keyUp" })); | |
| 185 | ||
| 186 | resolvers[1]?.("double-click"); | |
| 187 | await Promise.resolve(); | |
| 188 | await Promise.resolve(); | |
| 189 | ||
| 190 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 191 | expect(harness.notifications).toEqual([]); | |
| 192 | }); | |
| 193 | ||
| 194 | it("discards a stale mode resolution from a superseded press", async () => { | |
| 195 | // Press #1's mode is still pending when the user releases and | |
| 134 | 196 | // presses again; its late resolution must not act for press #2. |
| 135 | const resolvers: Array<(enabled: boolean) => void> = []; | |
| 197 | const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; | |
| 136 | 198 | const harness = makeHarness({ |
| 137 | isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), | |
| 199 | getMode: () => new Promise((resolve) => resolvers.push(resolve)), | |
| 138 | 200 | }); |
| 139 | 201 | await harness.send(makeInput({})); |
| 140 | 202 | await harness.send(makeInput({ type: "keyUp" })); |
| 141 | // Outside the double-tap window, so the second press starts a new hold. | |
| 142 | vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); | |
| 203 | // Outside the double-press window, so the second press starts a new hold. | |
| 204 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); | |
| 143 | 205 | await harness.send(makeInput({})); |
| 144 | 206 | expect(resolvers).toHaveLength(2); |
| 145 | 207 | |
| 146 | // Press #1 resolves late with "disabled" — it must not quit press #2. | |
| 147 | resolvers[0]?.(false); | |
| 208 | // Press #1 resolves late with "direct". It must not quit press #2. | |
| 209 | resolvers[0]?.("direct"); | |
| 148 | 210 | await Promise.resolve(); |
| 149 | 211 | await Promise.resolve(); |
| 150 | 212 | expect(harness.quit).not.toHaveBeenCalled(); |
| 151 | 213 | |
| 152 | // Press #2 resolves enabled and completes a full hold. | |
| 153 | resolvers[1]?.(true); | |
| 214 | // Press #2 resolves to hold and completes the gesture. | |
| 215 | resolvers[1]?.("hold"); | |
| 154 | 216 | await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); |
| 155 | 217 | await harness.send(makeInput({ type: "keyUp" })); |
| 156 | 218 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 157 | 219 | }); |
| 158 | 220 | |
| 159 | it("quits on a quick double tap, even when the first release was never seen", async () => { | |
| 160 | const harness = makeHarness(); | |
| 221 | it("quits on a quick double press in double-click mode when the first release is unseen", async () => { | |
| 222 | const harness = makeHarness({ mode: "double-click" }); | |
| 161 | 223 | await harness.send(makeInput({})); |
| 162 | vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); | |
| 224 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); | |
| 163 | 225 | await harness.send(makeInput({})); |
| 164 | 226 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 227 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); | |
| 228 | }); | |
| 229 | ||
| 230 | it("keeps the double-press hint visible after key release until the window ends", async () => { | |
| 231 | const harness = makeHarness({ mode: "double-click" }); | |
| 232 | await harness.send(makeInput({})); | |
| 233 | vi.advanceTimersByTime(100); | |
| 234 | await harness.send(makeInput({ type: "keyUp" })); | |
| 235 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); | |
| 236 | ||
| 237 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); | |
| 238 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); | |
| 239 | vi.advanceTimersByTime(1); | |
| 240 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); | |
| 165 | 241 | }); |
| 166 | 242 | |
| 167 | it("treats two slow taps as separate presses", async () => { | |
| 243 | it("accepts a second full shortcut after the modifier is released and pressed again", async () => { | |
| 244 | const harness = makeHarness({ mode: "double-click" }); | |
| 245 | await harness.send(makeInput({})); | |
| 246 | await harness.send(makeInput({ type: "keyUp" })); | |
| 247 | await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); | |
| 248 | vi.advanceTimersByTime(100); | |
| 249 | ||
| 250 | await harness.send(makeInput({ key: "Meta" })); | |
| 251 | await harness.send(makeInput({})); | |
| 252 | ||
| 253 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 254 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); | |
| 255 | }); | |
| 256 | ||
| 257 | it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { | |
| 258 | let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; | |
| 259 | const harness = makeHarness({ | |
| 260 | getMode: () => | |
| 261 | new Promise((resolve) => { | |
| 262 | resolveMode = resolve; | |
| 263 | }), | |
| 264 | }); | |
| 265 | await harness.send(makeInput({})); | |
| 266 | vi.advanceTimersByTime(100); | |
| 267 | await harness.send(makeInput({ type: "keyUp" })); | |
| 268 | vi.advanceTimersByTime(100); | |
| 269 | resolveMode?.("double-click"); | |
| 270 | await Promise.resolve(); | |
| 271 | await Promise.resolve(); | |
| 272 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); | |
| 273 | ||
| 274 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); | |
| 275 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); | |
| 276 | vi.advanceTimersByTime(1); | |
| 277 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); | |
| 278 | }); | |
| 279 | ||
| 280 | it("treats two slow presses as separate attempts in double-click mode", async () => { | |
| 281 | const harness = makeHarness({ mode: "double-click" }); | |
| 282 | await harness.send(makeInput({})); | |
| 283 | await harness.send(makeInput({ type: "keyUp" })); | |
| 284 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); | |
| 285 | await harness.send(makeInput({})); | |
| 286 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 287 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); | |
| 288 | }); | |
| 289 | ||
| 290 | it("does not treat two quick presses as a quit in hold mode", async () => { | |
| 168 | 291 | const harness = makeHarness(); |
| 169 | 292 | await harness.send(makeInput({})); |
| 170 | 293 | await harness.send(makeInput({ type: "keyUp" })); |
| 171 | vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); | |
| 294 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); | |
| 172 | 295 | await harness.send(makeInput({})); |
| 173 | 296 | expect(harness.quit).not.toHaveBeenCalled(); |
| 174 | expect(harness.notifications).toEqual(["down", "up", "down"]); | |
| 297 | expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); | |
| 175 | 298 | }); |
| 176 | 299 | |
| 177 | 300 | it("cancels the hold when another key interrupts it", async () => { |
| ... | ... | @@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => { |
| 180 | 303 | await harness.holdFor(500); |
| 181 | 304 | // Shift pressed mid-hold breaks the gesture... |
| 182 | 305 | await harness.send(makeInput({ shift: true })); |
| 183 | expect(harness.notifications).toEqual(["down", "up"]); | |
| 306 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | |
| 184 | 307 | // ...so later repeats past the threshold must not quit. |
| 185 | 308 | await harness.holdFor(QUIT_HOLD_DURATION_MS); |
| 186 | 309 | expect(harness.quit).not.toHaveBeenCalled(); |
| 187 | 310 | }); |
| 188 | 311 | |
| 189 | it("does not count an interrupted press toward a double tap", async () => { | |
| 190 | const harness = makeHarness(); | |
| 312 | it("does not count an interrupted press toward a double press", async () => { | |
| 313 | const harness = makeHarness({ mode: "double-click" }); | |
| 191 | 314 | await harness.send(makeInput({})); |
| 192 | 315 | await harness.send(makeInput({ shift: true })); |
| 193 | // A fresh press right after the interruption starts a new hold, not a | |
| 194 | // double-tap quit. | |
| 316 | // A fresh press right after the interruption starts a new attempt. | |
| 195 | 317 | await harness.send(makeInput({})); |
| 196 | 318 | expect(harness.quit).not.toHaveBeenCalled(); |
| 197 | expect(harness.notifications).toEqual(["down", "up", "down"]); | |
| 319 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); | |
| 198 | 320 | }); |
| 199 | 321 | |
| 200 | 322 | it("ignores other shortcuts", async () => { |
apps/desktop/src/window/QuitHold.ts+73-44| ... | ... | @@ -1,15 +1,12 @@ |
| 1 | 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 | 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 after the shortcut has been held for QUIT_HOLD_DURATION_MS | |
| 6 | // and released. | |
| 7 | // A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap | |
| 8 | // within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application | |
| 9 | // menu itself is untouched and quits immediately. | |
| 3 | import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | // The quit accelerator is intercepted in before-input-event, which runs | |
| 6 | // before the native menu accelerator. Quitting from the application menu is | |
| 7 | // untouched and always quits immediately. | |
| 10 | 8 | export const QUIT_HOLD_DURATION_MS = 1200; |
| 11 | // A second quick tap of the shortcut is the user insisting: quit immediately. | |
| 12 | export const QUIT_DOUBLE_TAP_MS = 500; | |
| 9 | export const QUIT_DOUBLE_PRESS_MS = 500; | |
| 13 | 10 | // "Still held" is proven by auto-repeat keydowns, not by the absence of a |
| 14 | 11 | // release: macOS suppresses a letter keyUp while the command key is down, so a |
| 15 | 12 | // tap release can go completely unseen and a release-based timer would quit |
| ... | ... | @@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500; |
| 18 | 15 | // auto-repeat disabled fall back to the application menu Quit action. |
| 19 | 16 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; |
| 20 | 17 | |
| 21 | export type QuitHoldState = "down" | "up"; | |
| 22 | ||
| 23 | 18 | export interface QuitHoldKeyInput { |
| 24 | 19 | readonly type: string; |
| 25 | 20 | readonly key: string; |
| ... | ... | @@ -30,27 +25,29 @@ export interface QuitHoldKeyInput { |
| 30 | 25 | readonly isAutoRepeat: boolean; |
| 31 | 26 | } |
| 32 | 27 | |
| 33 | export interface QuitHoldOptions { | |
| 28 | export interface QuitShortcutOptions { | |
| 34 | 29 | readonly platform: NodeJS.Platform; |
| 35 | readonly isEnabled: () => Promise<boolean>; | |
| 36 | readonly notify: (state: QuitHoldState) => void; | |
| 30 | readonly getMode: () => Promise<QuitConfirmationMode>; | |
| 31 | readonly notify: (event: QuitShortcutHintEvent) => void; | |
| 37 | 32 | readonly quit: () => void; |
| 38 | 33 | } |
| 39 | 34 | |
| 40 | export function makeQuitHoldHandler( | |
| 41 | options: QuitHoldOptions, | |
| 35 | export function makeQuitShortcutHandler( | |
| 36 | options: QuitShortcutOptions, | |
| 42 | 37 | ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { |
| 43 | 38 | const modifierKey = options.platform === "darwin" ? "meta" : "control"; |
| 44 | 39 | let watchdog: NodeJS.Timeout | undefined; |
| 45 | 40 | let holding = false; |
| 46 | // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. | |
| 41 | let mode: QuitConfirmationMode | undefined; | |
| 42 | let notified = false; | |
| 43 | // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. | |
| 47 | 44 | let armed = false; |
| 48 | 45 | let quitOnRelease = false; |
| 49 | 46 | let heldSince = 0; |
| 50 | 47 | let lastPressAt = 0; |
| 51 | // Incremented on every new press and every release/quit so a pending | |
| 52 | // isEnabled() resolution from a superseded press cannot arm (or quit for) | |
| 53 | // the current one. | |
| 48 | // Incremented when a press is superseded or explicitly cancelled. A plain | |
| 49 | // key release does not invalidate its pending mode read: direct mode and a | |
| 50 | // completed second press must still be honored after that read settles. | |
| 54 | 51 | let generation = 0; |
| 55 | 52 | |
| 56 | 53 | const clearWatchdog = () => { |
| ... | ... | @@ -60,19 +57,24 @@ export function makeQuitHoldHandler( |
| 60 | 57 | } |
| 61 | 58 | }; |
| 62 | 59 | |
| 63 | const release = () => { | |
| 64 | if (!holding) return; | |
| 65 | const shouldNotify = armed || quitOnRelease; | |
| 66 | generation += 1; | |
| 60 | const release = (cancelPendingMode = true, keepDoublePressHint = false) => { | |
| 61 | if (!holding && !notified) return; | |
| 62 | const keepHint = keepDoublePressHint && mode === "double-click" && notified; | |
| 63 | if (cancelPendingMode) generation += 1; | |
| 67 | 64 | holding = false; |
| 68 | 65 | armed = false; |
| 69 | 66 | quitOnRelease = false; |
| 67 | if (keepHint) return; | |
| 68 | ||
| 69 | mode = undefined; | |
| 70 | 70 | clearWatchdog(); |
| 71 | if (shouldNotify) options.notify("up"); | |
| 71 | if (notified) { | |
| 72 | notified = false; | |
| 73 | options.notify({ state: "up" }); | |
| 74 | } | |
| 72 | 75 | }; |
| 73 | 76 | |
| 74 | // Dismisses any overlay first: if the quit is cancelled downstream the | |
| 75 | // renderer must not be left with a stuck "Hold to Quit" hint. | |
| 77 | // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. | |
| 76 | 78 | const quitNow = () => { |
| 77 | 79 | release(); |
| 78 | 80 | options.quit(); |
| ... | ... | @@ -83,11 +85,11 @@ export function makeQuitHoldHandler( |
| 83 | 85 | if (input.type === "keyUp") { |
| 84 | 86 | if (key === "q") { |
| 85 | 87 | const shouldQuit = quitOnRelease; |
| 86 | release(); | |
| 88 | release(false, true); | |
| 87 | 89 | if (shouldQuit) options.quit(); |
| 88 | 90 | } else if (key === modifierKey) { |
| 89 | 91 | if (!quitOnRelease) { |
| 90 | release(); | |
| 92 | release(false, true); | |
| 91 | 93 | } else { |
| 92 | 94 | watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); |
| 93 | 95 | } |
| ... | ... | @@ -104,13 +106,17 @@ export function makeQuitHoldHandler( |
| 104 | 106 | |
| 105 | 107 | const modifierDown = options.platform === "darwin" ? input.meta : input.control; |
| 106 | 108 | if (!modifierDown || input.alt || input.shift || key !== "q") { |
| 109 | // Re-pressing the platform modifier is the first half of a second full | |
| 110 | // quit shortcut, so it must not cancel an active double-press window. | |
| 111 | if (key === modifierKey && !input.alt && !input.shift) return; | |
| 112 | ||
| 107 | 113 | // Any other key (or an extra modifier) pressed mid-hold breaks the |
| 108 | 114 | // gesture; without this the hold timer keeps running through the |
| 109 | 115 | // interruption and the next qualifying repeat would quit early. The |
| 110 | // interrupted press also stops counting toward a double tap — but only | |
| 116 | // interrupted press also stops counting toward a double press, but only | |
| 111 | 117 | // here, not in release(), which runs mid-restart on an unseen-release |
| 112 | 118 | // re-press and must not wipe that press's own tap timestamp. |
| 113 | if (holding && !input.isAutoRepeat) { | |
| 119 | if ((holding || notified) && !input.isAutoRepeat) { | |
| 114 | 120 | lastPressAt = 0; |
| 115 | 121 | release(); |
| 116 | 122 | } |
| ... | ... | @@ -120,7 +126,7 @@ export function makeQuitHoldHandler( |
| 120 | 126 | event.preventDefault(); |
| 121 | 127 | |
| 122 | 128 | if (input.isAutoRepeat) { |
| 123 | if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { | |
| 129 | if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { | |
| 124 | 130 | armed = false; |
| 125 | 131 | quitOnRelease = true; |
| 126 | 132 | clearWatchdog(); |
| ... | ... | @@ -131,28 +137,51 @@ export function makeQuitHoldHandler( |
| 131 | 137 | const now = Date.now(); |
| 132 | 138 | const previousPressAt = lastPressAt; |
| 133 | 139 | lastPressAt = now; |
| 134 | // A fresh keydown while "holding" means the key came back down after a | |
| 135 | // release macOS never delivered — so both branches below see real taps. | |
| 136 | if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { | |
| 137 | quitNow(); | |
| 138 | return; | |
| 139 | } | |
| 140 | if (holding) release(); | |
| 140 | // A fresh keydown supersedes the current physical hold or the hint kept | |
| 141 | // alive after a detected release. | |
| 142 | if (holding || notified) release(); | |
| 141 | 143 | |
| 142 | 144 | generation += 1; |
| 143 | 145 | const pressGeneration = generation; |
| 144 | 146 | holding = true; |
| 145 | 147 | heldSince = now; |
| 146 | void options.isEnabled().then( | |
| 147 | (enabled) => { | |
| 148 | void options.getMode().then( | |
| 149 | (resolvedMode) => { | |
| 148 | 150 | if (generation !== pressGeneration) return; |
| 149 | if (!enabled) { | |
| 150 | // Hold-to-quit disabled: a single press quits immediately. | |
| 151 | if (resolvedMode === "direct") { | |
| 151 | 152 | quitNow(); |
| 152 | 153 | return; |
| 153 | 154 | } |
| 155 | if ( | |
| 156 | resolvedMode === "double-click" && | |
| 157 | previousPressAt !== 0 && | |
| 158 | now - previousPressAt <= QUIT_DOUBLE_PRESS_MS | |
| 159 | ) { | |
| 160 | quitNow(); | |
| 161 | return; | |
| 162 | } | |
| 163 | ||
| 164 | if (resolvedMode === "double-click") { | |
| 165 | const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); | |
| 166 | if (remainingMs <= 0) { | |
| 167 | release(); | |
| 168 | return; | |
| 169 | } | |
| 170 | mode = resolvedMode; | |
| 171 | notified = true; | |
| 172 | options.notify({ state: "down", mode: resolvedMode }); | |
| 173 | watchdog = setTimeout(release, remainingMs); | |
| 174 | return; | |
| 175 | } | |
| 176 | ||
| 177 | // A hold cannot be armed after its physical press has ended. | |
| 178 | if (!holding) return; | |
| 179 | ||
| 180 | mode = resolvedMode; | |
| 181 | notified = true; | |
| 182 | options.notify({ state: "down", mode: resolvedMode }); | |
| 183 | ||
| 154 | 184 | armed = true; |
| 155 | options.notify("down"); | |
| 156 | 185 | // No auto-repeat by then means the key was released (possibly with a |
| 157 | 186 | // suppressed keyUp) or repeat is disabled; either way, don't quit. |
| 158 | 187 | watchdog = setTimeout(() => { |
apps/web/src/components/QuitHoldOverlay.tsx+20-13| ... | ... | @@ -2,29 +2,34 @@ import { useEffect, useState } from "react"; |
| 2 | 2 | |
| 3 | 3 | import { isMacPlatform } from "../lib/utils"; |
| 4 | 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; | |
| 5 | // A released hold hint lingers for the original hold duration. Double-press | |
| 6 | // hints disappear as soon as their acceptance window closes. | |
| 7 | const HOLD_HINT_LINGER_MS = 1200; | |
| 8 | 8 | |
| 9 | 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. | |
| 10 | * The desktop main process intercepts the quit accelerator and pushes | |
| 11 | * press/release states while it waits for a hold or second press. | |
| 13 | 12 | */ |
| 14 | 13 | export function QuitHoldOverlay() { |
| 15 | const [visible, setVisible] = useState(false); | |
| 14 | const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null); | |
| 16 | 15 | |
| 17 | 16 | useEffect(() => { |
| 18 | 17 | const subscribe = window.desktopBridge?.onQuitShortcut; |
| 19 | 18 | if (!subscribe) return; |
| 20 | 19 | let hideTimer: number | undefined; |
| 21 | const unsubscribe = subscribe((state) => { | |
| 20 | let pressedMode: "hold" | "double-click" = "hold"; | |
| 21 | const unsubscribe = subscribe((hint) => { | |
| 22 | 22 | window.clearTimeout(hideTimer); |
| 23 | if (state === "down") { | |
| 24 | setVisible(true); | |
| 23 | if (hint.state === "down") { | |
| 24 | pressedMode = hint.mode; | |
| 25 | setVisibleMode(hint.mode); | |
| 25 | 26 | return; |
| 26 | 27 | } |
| 27 | hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); | |
| 28 | if (pressedMode === "double-click") { | |
| 29 | setVisibleMode(null); | |
| 30 | return; | |
| 31 | } | |
| 32 | hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); | |
| 28 | 33 | }); |
| 29 | 34 | return () => { |
| 30 | 35 | window.clearTimeout(hideTimer); |
| ... | ... | @@ -32,15 +37,17 @@ export function QuitHoldOverlay() { |
| 32 | 37 | }; |
| 33 | 38 | }, []); |
| 34 | 39 | |
| 35 | if (!visible) return null; | |
| 40 | if (!visibleMode) return null; | |
| 36 | 41 | const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; |
| 42 | const message = | |
| 43 | visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`; | |
| 37 | 44 | return ( |
| 38 | 45 | <div |
| 39 | 46 | role="status" |
| 40 | 47 | className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center" |
| 41 | 48 | > |
| 42 | 49 | <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 | |
| 50 | {message} | |
| 44 | 51 | </div> |
| 45 | 52 | </div> |
| 46 | 53 | ); |
apps/web/src/components/settings/SettingsPanels.tsx+29-10| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | MIN_PROMPT_FONT_SIZE, |
| 35 | 35 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 36 | 36 | MIN_TERMINAL_FONT_SIZE, |
| 37 | type QuitConfirmationMode, | |
| 37 | 38 | } from "@t3tools/contracts/settings"; |
| 38 | 39 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 39 | 40 | import { createModelSelection } from "@t3tools/shared/model"; |
| ... | ... | @@ -163,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 163 | 164 | "24-hour": "24-hour", |
| 164 | 165 | } as const; |
| 165 | 166 | |
| 167 | const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = { | |
| 168 | direct: "Direct", | |
| 169 | hold: "Hold", | |
| 170 | "double-click": "Double press", | |
| 171 | }; | |
| 172 | ||
| 166 | 173 | const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = { |
| 167 | 174 | balanced: "Balanced", |
| 168 | 175 | performance: "Performance", |
| ... | ... | @@ -542,9 +549,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 542 | 549 | ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete |
| 543 | 550 | ? ["Delete confirmation"] |
| 544 | 551 | : []), |
| 545 | ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit | |
| 546 | ? ["Quit confirmation"] | |
| 547 | : []), | |
| 552 | ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), | |
| 548 | 553 | ...(isTextGenerationModelDirty ? ["Text generation model"] : []), |
| 549 | 554 | ...getChangedBrowserSettingLabels(settings), |
| 550 | 555 | ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess |
| ... | ... | @@ -2422,11 +2427,11 @@ export function GeneralSettingsPanel() { |
| 2422 | 2427 | {isElectron ? ( |
| 2423 | 2428 | <SettingsRow |
| 2424 | 2429 | {...searchableSetting("quit-confirmation")} |
| 2425 | description="Require holding the quit shortcut before the desktop app quits. A quick tap shows a hint instead." | |
| 2430 | description="Choose whether the desktop app quits immediately, after a hold, or after two quick presses." | |
| 2426 | 2431 | resetAction={ |
| 2427 | 2432 | settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ( |
| 2428 | 2433 | <SettingResetButton |
| 2429 | label="quit confirmation" | |
| 2434 | label="quit shortcut behavior" | |
| 2430 | 2435 | onClick={() => |
| 2431 | 2436 | updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) |
| 2432 | 2437 | } |
| ... | ... | @@ -2434,11 +2439,25 @@ export function GeneralSettingsPanel() { |
| 2434 | 2439 | ) : null |
| 2435 | 2440 | } |
| 2436 | 2441 | control={ |
| 2437 | <Switch | |
| 2438 | checked={settings.confirmQuit} | |
| 2439 | onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })} | |
| 2440 | aria-label="Hold to quit" | |
| 2441 | /> | |
| 2442 | <Select | |
| 2443 | value={settings.confirmQuit} | |
| 2444 | onValueChange={(value) => { | |
| 2445 | if (value === "direct" || value === "hold" || value === "double-click") { | |
| 2446 | updateSettings({ confirmQuit: value }); | |
| 2447 | } | |
| 2448 | }} | |
| 2449 | > | |
| 2450 | <SelectTrigger className="w-full sm:w-40" aria-label="Quit shortcut behavior"> | |
| 2451 | <SelectValue>{QUIT_CONFIRMATION_MODE_LABELS[settings.confirmQuit]}</SelectValue> | |
| 2452 | </SelectTrigger> | |
| 2453 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 2454 | {Object.entries(QUIT_CONFIRMATION_MODE_LABELS).map(([value, label]) => ( | |
| 2455 | <SelectItem hideIndicator key={value} value={value}> | |
| 2456 | {label} | |
| 2457 | </SelectItem> | |
| 2458 | ))} | |
| 2459 | </SelectPopup> | |
| 2460 | </Select> | |
| 2442 | 2461 | } |
| 2443 | 2462 | /> |
| 2444 | 2463 | ) : null} |
apps/web/src/components/settings/settingsSearch.ts+2-2| ... | ... | @@ -238,9 +238,9 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 238 | 238 | }, |
| 239 | 239 | { |
| 240 | 240 | id: "quit-confirmation", |
| 241 | title: "Hold to quit", | |
| 241 | title: "Quit shortcut", | |
| 242 | 242 | to: "/settings/general", |
| 243 | searchTerms: ["confirmation shortcut desktop app exit"], | |
| 243 | searchTerms: ["confirmation desktop app exit direct hold double click press twice"], | |
| 244 | 244 | desktopOnly: true, |
| 245 | 245 | }, |
| 246 | 246 | { |
packages/contracts/src/ipc.ts+8-5| ... | ... | @@ -91,7 +91,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; |
| 91 | 91 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 92 | 92 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 93 | 93 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 94 | import type { ClientSettings } from "./settings.ts"; | |
| 94 | import type { ClientSettings, QuitConfirmationMode } from "./settings.ts"; | |
| 95 | 95 | import type { EditorId } from "./editor.ts"; |
| 96 | 96 | import type { |
| 97 | 97 | SourceControlCloneRepositoryInput, |
| ... | ... | @@ -116,6 +116,10 @@ export interface ContextMenuItem<T extends string = string> { |
| 116 | 116 | children?: readonly ContextMenuItem<T>[]; |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | export type QuitShortcutHintEvent = | |
| 120 | | { readonly state: "down"; readonly mode: Exclude<QuitConfirmationMode, "direct"> } | |
| 121 | | { readonly state: "up" }; | |
| 122 | ||
| 119 | 123 | export interface ContextMenuItemSchemaType { |
| 120 | 124 | readonly id: string; |
| 121 | 125 | readonly label: string; |
| ... | ... | @@ -1140,11 +1144,10 @@ export interface DesktopBridge { |
| 1140 | 1144 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1141 | 1145 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1142 | 1146 | /** |
| 1143 | * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, | |
| 1144 | * "up" when it is released before the hold completes. Optional: older | |
| 1145 | * desktop builds never emit it. | |
| 1147 | * Quit-confirmation hint pushes. Optional: older desktop builds never emit | |
| 1148 | * them. | |
| 1146 | 1149 | */ |
| 1147 | onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; | |
| 1150 | onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; | |
| 1148 | 1151 | getWindowFullscreenState: () => boolean; |
| 1149 | 1152 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
| 1150 | 1153 | getUpdateState: () => Promise<DesktopUpdateState>; |
packages/contracts/src/settings.test.ts+26| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | |
| 16 | 16 | const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); |
| 17 | 17 | const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); |
| 18 | const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); | |
| 18 | 19 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 19 | 20 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 20 | 21 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| ... | ... | @@ -66,6 +67,31 @@ describe("ClientSettings word wrap", () => { |
| 66 | 67 | }); |
| 67 | 68 | }); |
| 68 | 69 | |
| 70 | describe("ClientSettings quit confirmation", () => { | |
| 71 | it("defaults to hold", () => { | |
| 72 | expect(decodeClientSettings({}).confirmQuit).toBe("hold"); | |
| 73 | }); | |
| 74 | ||
| 75 | it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => { | |
| 76 | expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode); | |
| 77 | expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode); | |
| 78 | }); | |
| 79 | ||
| 80 | it.each([ | |
| 81 | [true, "hold"], | |
| 82 | [false, "direct"], | |
| 83 | ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => { | |
| 84 | const settings = decodeClientSettings({ confirmQuit: legacyValue }); | |
| 85 | ||
| 86 | expect(settings.confirmQuit).toBe(mode); | |
| 87 | expect(encodeClientSettings(settings).confirmQuit).toBe(mode); | |
| 88 | }); | |
| 89 | ||
| 90 | it("rejects legacy booleans at the patch boundary", () => { | |
| 91 | expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow(); | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 69 | 95 | describe("ClientSettings browser recording frame rate", () => { |
| 70 | 96 | it("defaults to 30 fps", () => { |
| 71 | 97 | expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30); |
packages/contracts/src/settings.ts+22-4| ... | ... | @@ -124,6 +124,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", |
| 124 | 124 | export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; |
| 125 | 125 | export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; |
| 126 | 126 | |
| 127 | export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); | |
| 128 | export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; | |
| 129 | export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; | |
| 130 | ||
| 131 | const LegacyConfirmQuit = Schema.Boolean.pipe( | |
| 132 | Schema.decodeTo( | |
| 133 | QuitConfirmationMode, | |
| 134 | SchemaTransformation.transform({ | |
| 135 | decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), | |
| 136 | encode: (mode) => mode === "hold", | |
| 137 | }), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); | |
| 142 | ||
| 127 | 143 | /** |
| 128 | 144 | * A user-chosen font family (a single name or a comma-separated list). Empty |
| 129 | 145 | * means "use the app default"; clients compose their own fallback stacks. |
| ... | ... | @@ -183,9 +199,11 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 183 | 199 | browserAutoShowFloatingPreview: Schema.Boolean.pipe( |
| 184 | 200 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), |
| 185 | 201 | ), |
| 186 | // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the | |
| 187 | // app quits; a quick tap only shows a hint. Browser clients ignore it. | |
| 188 | confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 202 | // Desktop-only. Boolean values from older settings files decode to their | |
| 203 | // equivalent mode and encode back as the canonical string value. | |
| 204 | confirmQuit: QuitConfirmationModeSetting.pipe( | |
| 205 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), | |
| 206 | ), | |
| 189 | 207 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 190 | 208 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 191 | 209 | confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| ... | ... | @@ -938,7 +956,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 938 | 956 | browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), |
| 939 | 957 | browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), |
| 940 | 958 | browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), |
| 941 | confirmQuit: Schema.optionalKey(Schema.Boolean), | |
| 959 | confirmQuit: Schema.optionalKey(QuitConfirmationMode), | |
| 942 | 960 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 943 | 961 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 944 | 962 | confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), |